136 Commits
v0.4.1 ... main

Author SHA1 Message Date
23203f235c Clarify credential redaction and tool registration in release notes 2026-11-03 00:41:40 +00:00
37d6209aec Prepare v0.8.0 notes and retire completed roadmaps 2026-11-03 00:29:51 +00:00
ee72566263 Verify vendored recovery after launcher cleanup 2026-11-03 00:23:06 +00:00
e0019abe31 Validate byte-preserving record fields as base64 strings 2026-11-03 00:18:47 +00:00
2a7bed87db Classify recovery setup failures by their actual cause 2026-11-03 00:15:36 +00:00
dfaa652098 Keep recovery handoffs available after publication cancellation 2026-11-03 00:12:30 +00:00
cd08a2892a Run API-only recovery independently of consumer vendoring 2026-11-03 00:05:32 +00:00
16d1c072e3 Record remaining recovery findings and recommended fixes 2026-11-03 00:00:42 +00:00
363f7cd513 Retain discovered publication records when enumeration fails 2026-11-02 22:41:18 +00:00
cd274512e7 Reject trailing record JSON without decoding a second value 2026-11-02 22:37:12 +00:00
3b8bacf114 Filter publication record names before sorting directory results 2026-11-02 22:36:35 +00:00
781341a0ba Preserve exact evidence bytes in private publication records 2026-11-02 22:35:53 +00:00
109596bc3b Use the source-built CLI in maintainer recovery instructions 2026-11-02 22:34:19 +00:00
6fc140d8dc Verify record storage preserves whitespace in Git paths 2026-11-02 22:33:29 +00:00
993866c1d7 Keep publication recovery usable after cold Go tool runs 2026-11-02 22:32:58 +00:00
19a14bfeb6 Report JSON output failures from recovery commands 2026-11-02 22:23:15 +00:00
648c87bb8a Redact credentials throughout publication recovery displays 2026-11-02 22:22:31 +00:00
04db23705a Honor cancellation throughout publication recovery Git operations 2026-11-02 22:20:53 +00:00
9a7b9b9301 Allow explicit publication stores in Git metadata 2026-11-02 22:18:51 +00:00
d2b1329095 Check the exact publication record filename before writing 2026-11-02 22:17:59 +00:00
0d19a5546f Preserve candidate failures when initializing publication records 2026-11-02 22:17:29 +00:00
da1839c6d2 Record approved recovery remediation decisions 2026-11-02 22:13:04 +00:00
957e75524c Record publication recovery algorithm findings 2026-11-02 21:13:44 +00:00
0ef135bfda Record publication recovery integration findings 2026-11-02 20:53:01 +00:00
3280f734ef Record publication recovery review findings 2026-11-02 20:24:44 +00:00
599ad81bec Finish publication recovery workflow 2026-11-02 19:37:29 +00:00
6915a86132 Add publication recovery handoff to CLI 2026-11-02 19:22:23 +00:00
4afdd9b826 Add CLI publication recovery commands 2026-11-02 19:17:42 +00:00
fb5947ea3a Add local publication attempt discovery 2026-11-02 19:09:26 +00:00
2a04938a96 Record publication evidence before tag pushes 2026-11-02 19:08:07 +00:00
b11c6d1788 Expose publication recovery facade 2026-11-02 19:03:24 +00:00
1ddeb2c6dc Add private publication attempt records 2026-11-02 19:00:51 +00:00
4dff8086ab Extract publication recovery observation 2026-11-02 18:58:48 +00:00
447b42a1cd Plan publication reconciliation implementation 2026-11-02 18:24:44 +00:00
58656dea45 Clarify recovery transport settings and partial discovery 2026-11-02 17:57:05 +00:00
58c20fa863 Add recovery ergonomics to reconciliation roadmap 2026-11-02 17:43:27 +00:00
f73f0c1b6a Clarify publication recording and recovery selection 2026-11-02 17:40:38 +00:00
bff4ed8a8b Resolve publication reconciliation roadmap decisions 2026-11-02 17:17:23 +00:00
e0bf5ca57a Plan read-only publication reconciliation 2026-11-02 16:50:16 +00:00
691926d839 Run consumer release commands through Go tools 2026-11-02 14:46:42 +00:00
6fab30337a Prepare v0.7.0 notes and retire completed roadmaps 2026-11-02 13:54:16 +00:00
e736166a39 Hide credentials in checkout preparation errors 2026-11-02 11:45:42 +00:00
a2d75c462c Preserve child working directory in environment overlays 2026-11-02 11:36:40 +00:00
31f9e48219 Redact quoted credentials in Git diagnostics 2026-11-02 11:32:11 +00:00
473670185c Record release workflow integration gaps 2026-11-02 04:06:35 +00:00
c8fa165dc9 Record the implementation review finding 2026-11-02 03:53:36 +00:00
2f95753d9c Verify the audit remediation changes 2026-11-02 03:41:01 +00:00
9dc87ae996 Record deferred resource assessments 2026-11-02 03:36:53 +00:00
d9c97fd915 Remove unused helpers and incidental assertions 2026-11-02 03:34:59 +00:00
1d40fb7ac0 Extract check and build command workflows 2026-11-02 03:34:06 +00:00
03ee83e8a5 Separate validation commands from tree checks 2026-11-02 03:32:46 +00:00
9cb48a70b7 Bound retained Go command diagnostics 2026-11-02 03:31:59 +00:00
2b23574ece Bound remote asset content verification 2026-11-02 03:29:59 +00:00
76676ee5a6 Limit release asset redirects 2026-11-02 03:28:24 +00:00
23b3a3d340 Preserve delivery outcomes when cleanup fails 2026-11-02 03:27:13 +00:00
ef47356541 Report temporary metadata cleanup failures 2026-11-02 03:24:57 +00:00
0666b68ff6 Share artifact declaration validation 2026-11-02 03:23:15 +00:00
6f2e62bea3 Report only fully hashed build artifacts 2026-11-02 03:22:07 +00:00
aa57fee4de Preserve evidence when local tag inspection fails 2026-11-02 03:20:13 +00:00
92c95ef031 Hide credentials in Git endpoint diagnostics 2026-11-02 03:19:16 +00:00
595745c7b1 Close piped processes on cancellation 2026-11-02 03:16:36 +00:00
9c02ed769b Bound subprocess output teardown 2026-11-02 03:14:59 +00:00
94aedacf05 Verify releases use original Git objects 2026-11-02 03:14:01 +00:00
7e6e0cc7d1 Observe original Git objects consistently 2026-11-02 03:12:05 +00:00
aecab00ff6 Ensure tag pushes ignore submodule push settings 2026-11-02 03:08:58 +00:00
a1dc705be7 Plan the audit remediation sprint 2026-11-02 03:00:05 +00:00
b5b3f09599 Record accepted audit remediation decisions 2026-11-02 02:23:58 +00:00
6f7e43b097 Consolidate audit findings and remediation priorities 2026-11-02 02:16:40 +00:00
91f25c452c Record dead code audit findings 2026-11-02 01:47:21 +00:00
083bc83331 Record shared behavior audit findings 2026-11-02 01:45:54 +00:00
5e6db4de76 Record interface contract audit finding 2026-11-02 01:44:55 +00:00
b345bc0ec4 Record test coverage audit findings 2026-11-02 01:43:27 +00:00
3d7a691338 Record error handling audit finding 2026-11-02 01:41:31 +00:00
3754242969 Record resource management audit findings 2026-11-02 01:40:06 +00:00
9545bb0bfc Record complexity audit findings 2026-11-02 01:39:06 +00:00
620c832477 Record efficiency audit findings 2026-11-02 01:38:01 +00:00
812c24de9e Record security audit findings 2026-11-02 01:27:24 +00:00
1b39e71e81 Record concurrency audit findings 2026-11-02 01:16:22 +00:00
d884ad731d Record functional correctness audit findings 2026-11-02 01:07:48 +00:00
dd0a557ad3 Document local release page delivery after publication 2026-11-01 23:41:51 +00:00
b633ee8b4c Prepare v0.6.0 release notes and retire completed plans 2026-11-01 23:32:57 +00:00
fcd2e57887 Synchronize deadline regression with source inspection 2026-11-01 21:56:13 +00:00
66f5884ab4 Preserve discovery failures for damaged checkouts 2026-11-01 21:53:40 +00:00
5d5653bfc3 Clarify ReleaseKit ownership of remediation work 2026-11-01 21:52:51 +00:00
1ad6ddc198 Record remaining implementation review issues 2026-11-01 21:42:04 +00:00
67963102d9 Complete audit remediation and verification 2026-11-01 21:18:07 +00:00
d368bb32f9 Clarify page-only delivery and artifact verification 2026-11-01 21:15:28 +00:00
9fa440726e Classify missing release IDs when accessing assets 2026-11-01 21:15:08 +00:00
28a4144b49 Preserve cancellation during build source inspection 2026-11-01 21:14:34 +00:00
b91a0d1914 Validate derived candidate tags before checks 2026-11-01 21:13:54 +00:00
abbab3c8ce Verify preparation selects the intended module graph 2026-11-01 21:13:04 +00:00
05f1b472fc Use selected module for dependency downloads 2026-11-01 21:12:22 +00:00
5aba4c6c87 Anchor dependency preparation to checkout root 2026-11-01 21:12:05 +00:00
b47202a2e9 Discover checkout roots for optional preparation 2026-11-01 21:11:39 +00:00
0632aa5901 Treat incomplete remote tag observations as uncertain 2026-11-01 21:11:06 +00:00
730e6380c8 Write the staged audit remediation plan 2026-11-01 21:08:40 +00:00
59bb0745e7 Align preparation roadmap with automatic checkout root resolution 2026-11-01 20:56:15 +00:00
303385a5ee Plan remediation of correctness and interface audit findings 2026-11-01 20:47:53 +00:00
899e7f1bfc Record functional correctness audit findings 2026-11-01 20:38:53 +00:00
31f5327255 Record API and interface contract audit findings 2026-11-01 20:25:21 +00:00
3f00ad2754 Remove completed release delivery plans and audit records 2026-11-01 19:37:40 +00:00
e42cad7b1a Preserve asset inspection error causes through delivery 2026-11-01 19:33:44 +00:00
eb61b40f08 Sort only missing release assets once 2026-11-01 19:29:50 +00:00
b72af69baf Make pending asset bookkeeping linear 2026-11-01 19:29:17 +00:00
078db6cbcb Validate CI module tags before delivery preparation 2026-11-01 19:27:56 +00:00
9376bafaa2 Align completed forge roadmap status records 2026-11-01 19:26:52 +00:00
6bed1cd691 Clarify delivery exit statuses by failure phase 2026-11-01 19:26:30 +00:00
7b5c1285a2 Exclude forge contracts from runtime dependencies 2026-11-01 19:25:15 +00:00
e5512d50b0 Classify invalid delivery destinations during preflight 2026-11-01 19:24:52 +00:00
627d524aa2 Record algorithmic delivery audit findings 2026-11-01 19:22:04 +00:00
370fe3a9f5 Record delivery integration audit findings 2026-11-01 19:11:51 +00:00
72de18744b Record forge delivery review findings 2026-11-01 18:51:43 +00:00
cbdba90014 Document neutral forge delivery and preflight 2026-11-01 18:38:03 +00:00
c3f6eeb6df Complete forge delivery regression coverage 2026-11-01 18:30:52 +00:00
2e617db23c Connect neutral delivery to the Gitea CLI 2026-11-01 18:20:01 +00:00
b350c71246 Move asset reconciliation into shared delivery 2026-11-01 18:07:55 +00:00
88a79c3561 Move release page recovery into shared delivery 2026-11-01 18:04:30 +00:00
ddac9e3c73 Return write evidence from Gitea operations 2026-11-01 18:02:05 +00:00
02bd120aed Preflight Gitea credentials with repository reads 2026-11-01 17:59:43 +00:00
201af83d95 Add Gitea read operations to the forge boundary 2026-11-01 17:57:57 +00:00
61a07bc950 Move asset verification into the artifact package 2026-11-01 17:55:53 +00:00
aa03a78476 Define the internal forge delivery contract 2026-11-01 17:52:38 +00:00
9d6952a772 Plan credential preflight for forge delivery 2026-11-01 17:44:47 +00:00
44823ce323 Plan the neutral forge delivery refactor 2026-11-01 16:37:50 +00:00
71746f437e Require explicit forge selection in the neutral destination plan 2026-11-01 16:25:54 +00:00
2700bd18cb Plan the internal forge delivery boundary 2026-11-01 16:23:58 +00:00
ac78e7db73 Rename the candidate file validator to forbidden files 2026-11-01 16:02:51 +00:00
792bfc4eca Read delivery tokens from files and check selected paths before release 2026-11-01 15:39:36 +00:00
6650b639e1 Prepare v0.5.0 with the current Go toolchain and retire the migration roadmap 2026-11-01 14:44:40 +00:00
a1cac0898e Record completion and verification of the module migration 2026-11-01 14:41:36 +00:00
3b803d558b Migrate ReleaseKit to the vanity module path and new Gitea host 2026-11-01 14:37:41 +00:00
56a86d6d49 Clarify the Gitea hostname migration in the roadmap 2026-11-01 14:28:10 +00:00
f97edeb830 Plan the ReleaseKit vanity module migration 2026-11-01 14:24:43 +00:00
7d033c5ca1 Prepare v0.4.2 release notes and retire the completed bug report 2026-10-11 02:10:48 +00:00
f076da99b6 Resolve annotated tag events in Woodpecker delivery workflows 2026-10-11 02:07:54 +00:00
3f2a1a1052 Document Audita's annotated tag delivery failure 2026-10-11 01:54:26 +00:00
157 changed files with 12522 additions and 2949 deletions

View File

@@ -8,6 +8,12 @@ tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
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. delivery are documented. ReleaseKit does not declare binary deliverables.
The Go module is `go.maximumdirect.net/releasekit`. The
[adoption guide](docs/consumers/adoption.md#migrating-from-the-original-module-path)
explains the v0.5.0 migration from the original Gitea module path.
The [v0.6.0 release note](docs/releases/v0.6.0.md) describes the delivery API
and profile migration.
Release operations can select one Go module within a repository. The repository Release operations can select one Go module within a repository. The repository
root remains the default; a selected module's declared import path and physical 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 directory determine its complete Git tag and module-local release note. See the
@@ -75,6 +81,8 @@ failure recovery.
components and their responsibilities. components and their responsibilities.
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency - [Architecture policy](docs/policy/architecture.md): responsibility and dependency
boundaries for development. boundaries for development.
- [ADR 0006](docs/adr/0006-neutral-forge-delivery-and-preflight.md): neutral
delivery policy and credential preflight.
- [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision. - [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision.
- [Policy provenance](docs/policy/meta.md): adopted policy sources and update - [Policy provenance](docs/policy/meta.md): adopted policy sources and update
guidance. guidance.

View File

@@ -5,11 +5,11 @@ import (
"fmt" "fmt"
"io" "io"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "go.maximumdirect.net/releasekit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "go.maximumdirect.net/releasekit/internal/versioncontract"
) )
// Target identifies one explicit Go operating-system and architecture pair. // Target identifies one explicit Go operating-system and architecture pair.
@@ -145,10 +145,7 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
func internalBuildResult(result BuildResult) *internalbuild.Result { func internalBuildResult(result BuildResult) *internalbuild.Result {
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets}) input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.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} 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, OutputDir: result.OutputDir, Compiler: result.Compiler}
for _, target := range settings.Targets {
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
}
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {
converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256}) converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256})
} }

View File

@@ -0,0 +1,150 @@
package releasekit_test
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
releasekit "go.maximumdirect.net/releasekit"
)
// inspectionDeadlineContext models deadline expiration at the readiness signal,
// so unrelated setup time cannot consume the source-inspection test's deadline.
type inspectionDeadlineContext struct{ context.Context }
func (ctx inspectionDeadlineContext) Err() error {
return context.Cause(ctx.Context)
}
func TestBuildSourceInspectionCancellation(t *testing.T) {
const source = `package main
import("fmt";"os";"go.maximumdirect.net/releasekit/buildinfo")
func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} }
`
for _, tc := range []struct {
name string
status int
deadline bool
binary bool
}{
{"initial", 1, false, false}, {"deadline", 1, true, false}, {"final", 2, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
repo := newBuildConsumer(t, source)
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
count := filepath.Join(t.TempDir(), "count")
ready := filepath.Join(t.TempDir(), "ready")
script := `#!/bin/sh
for arg in "$@"; do
if [ "$arg" = status ]; then
count=0
if [ -f "$INSPECT_COUNT" ]; then count=$(/bin/cat "$INSPECT_COUNT"); fi
count=$((count+1)); printf '%s' "$count" > "$INSPECT_COUNT"
if [ "$count" = "$INSPECT_BLOCK" ]; then : > "$INSPECT_READY"; exec /bin/sleep 60; fi
fi
done
exec "$INSPECT_GIT" "$@"
`
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("INSPECT_GIT", realGit)
t.Setenv("INSPECT_COUNT", count)
t.Setenv("INSPECT_READY", ready)
t.Setenv("INSPECT_BLOCK", fmt.Sprint(tc.status))
var ctx context.Context
var cancel context.CancelFunc
if tc.deadline {
parent, expire := context.WithCancelCause(context.Background())
ctx = inspectionDeadlineContext{parent}
cancel = func() { expire(context.DeadlineExceeded) }
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel()
request := releasekit.BuildRequest{RepositoryDir: repo, Version: "v1.2.3", OutputDir: t.TempDir()}
if tc.binary {
request.Spec = releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
}
type outcome struct {
result releasekit.BuildResult
err error
}
done := make(chan outcome, 1)
go func() { result, err := releasekit.Build(ctx, request); done <- outcome{result, err} }()
timer := time.NewTimer(45 * time.Second)
defer timer.Stop()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
waiting := true
for waiting {
if _, err := os.Stat(ready); err == nil {
waiting = false
break
}
select {
case result := <-done:
t.Fatalf("build ended before inspection: %#v, %v", result.result, result.err)
case <-timer.C:
cancel()
result := <-done
t.Fatalf("inspection did not start: %#v, %v", result.result, result.err)
case <-ticker.C:
}
}
cancel()
var result outcome
select {
case result = <-done:
case <-timer.C:
t.Fatal("build did not stop after source inspection interruption")
}
want := context.Canceled
if tc.deadline {
want = context.DeadlineExceeded
}
if !errors.Is(result.err, want) || result.result.SourceVerified {
t.Fatalf("result = %#v, error = %v", result.result, result.err)
}
if tc.binary {
if len(result.result.Artifacts) != 1 || result.result.Artifacts[0].SHA256 == "" {
t.Fatalf("completed artifact missing: %#v", result.result.Artifacts)
}
if _, err := os.Stat(result.result.Artifacts[0].Path); err != nil {
t.Fatal(err)
}
}
})
}
}
func TestBuildSourceInspectionPreservesGitFailure(t *testing.T) {
repo := newBuildConsumer(t, "package main\nfunc main() {}\n")
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
script := "#!/bin/sh\nfor arg do if [ \"$arg\" = status ]; then exit 37; fi; done\nexec \"$INSPECT_GIT\" \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("INSPECT_GIT", realGit)
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: repo, Version: "v1.2.3"})
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 37 || result.SourceVerified {
t.Fatalf("result = %#v, error = %v", result, err)
}
}

View File

@@ -15,9 +15,44 @@ import (
"testing" "testing"
"time" "time"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "go.maximumdirect.net/releasekit"
) )
func TestBuildReplacementDerivedSourceIsNotEligibleForDelivery(t *testing.T) {
repository := newTaggedDeliveryConsumer(t)
original := buildGit(t, repository, "rev-parse", "HEAD")
mainPath := filepath.Join(repository, "cmd/app/main.go")
main, err := os.ReadFile(mainPath)
if err != nil {
t.Fatal(err)
}
writeBuildFixture(t, repository, "cmd/app/main.go", string(main)+"\n// replacement source\n")
buildGit(t, repository, "add", "cmd/app/main.go")
buildGit(t, repository, "commit", "-m", "Replacement source")
replacement := buildGit(t, repository, "rev-parse", "HEAD")
buildGit(t, repository, "reset", "--hard", original)
buildGit(t, repository, "replace", original, replacement)
buildGit(t, repository, "reset", "--hard", original)
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
})
if err != nil || result.SourceVerified || len(result.Artifacts) != 1 || result.CandidateCommit != original {
t.Fatalf("replacement build = %#v, error = %v", result, err)
}
buildGit(t, repository, "replace", "-d", original)
buildGit(t, repository, "reset", "--hard", original)
server, remote := newFacadeDeliveryServer(t, repository)
defer server.Close()
delivery, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &result,
})
if err == nil || delivery.State != releasekit.DeliveryNotAttempted || remote.snapshot().writes != 0 {
t.Fatalf("delivery of replacement build = %#v, error = %v", delivery, err)
}
}
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) { func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}} 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{ invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
@@ -48,7 +83,7 @@ import (
"fmt" "fmt"
"os" "os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { func main() {
@@ -127,7 +162,7 @@ func TestBuildUsesGoHostDespiteAmbientTargetEnvironment(t *testing.T) {
import ( import (
"fmt" "fmt"
"os" "os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { func main() {
@@ -159,7 +194,7 @@ func TestBuildIgnoresAmbientAndPersistentArchitectureTuning(t *testing.T) {
import ( import (
"fmt" "fmt"
"os" "os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } } func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } }
`) `)
@@ -272,7 +307,7 @@ func TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput(t *testing.T) {
import ( import (
"fmt" "fmt"
"os" "os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { func main() {
if len(os.Args) == 2 && os.Args[1] == "version" { if len(os.Args) == 2 && os.Args[1] == "version" {
@@ -406,7 +441,7 @@ func TestBuildResolvesDirtyWorkingTreeDeclaration(t *testing.T) {
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";"go.maximumdirect.net/releasekit/buildinfo")
func main(){fmt.Println("fixture",buildinfo.Version())} func main(){fmt.Println("fixture",buildinfo.Version())}
` `
repository := newBuildConsumer(t, source) repository := newBuildConsumer(t, source)
@@ -486,6 +521,61 @@ func TestBuildReportsMissingMainPackage(t *testing.T) {
} }
} }
func TestBuildRetainsOnlyHashedArtifactsAfterReadFailure(t *testing.T) {
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
realGo, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
count := filepath.Join(t.TempDir(), "count")
script := `#!/bin/sh
if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi
output=
previous=
for arg in "$@"; do
if [ "$previous" = -o ]; then output=$arg; fi
previous=$arg
done
"$RELEASEKIT_REAL_GO" "$@" || exit $?
if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then
rm "$output"
mkdir "$output"
else
: > "$RELEASEKIT_BUILD_COUNT"
fi
`
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("RELEASEKIT_REAL_GO", realGo)
t.Setenv("RELEASEKIT_BUILD_COUNT", count)
secondArch := "arm64"
if runtime.GOARCH == secondArch {
secondArch = "amd64"
}
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}, {OS: runtime.GOOS, Arch: secondArch}}},
})
var pathErr *os.PathError
if !errors.As(err, &pathErr) || !strings.Contains(err.Error(), "hash build output") || len(result.Artifacts) != 1 || len(result.Targets) != 2 || result.MainPackage != "./cmd/app" || result.BinaryName != "fixture" || result.CandidateCommit == "" || result.SourceVerified {
t.Fatalf("partial build = %#v, error = %v", result, err)
}
contents, readErr := os.ReadFile(result.Artifacts[0].Path)
if readErr != nil {
t.Fatal(readErr)
}
digest := sha256.Sum256(contents)
if result.Artifacts[0].SHA256 != hex.EncodeToString(digest[:]) {
t.Fatalf("completed artifact digest = %q", result.Artifacts[0].SHA256)
}
if info, statErr := os.Stat(pathErr.Path); statErr != nil || !info.IsDir() {
t.Fatalf("failed output was not retained: %v", statErr)
}
}
func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) { func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) {
repository := newBuildConsumer(t, `package main repository := newBuildConsumer(t, `package main
import ( import (
@@ -616,7 +706,7 @@ func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
import ( import (
"fmt" "fmt"
"os" "os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } } func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } }
`) `)
@@ -713,7 +803,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.26.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 go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/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")
@@ -731,7 +821,7 @@ func writeNestedBuildConsumer(t *testing.T, repository, moduleDir, modulePath, m
if err != nil { if err != nil {
t.Fatal(err) 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)) module := fmt.Sprintf("module %s\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/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, "go.mod")), module)
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "cmd/app/main.go")), mainSource) writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "cmd/app/main.go")), mainSource)
return filepath.Join(repository, filepath.FromSlash(moduleDir)) return filepath.Join(repository, filepath.FromSlash(moduleDir))
@@ -743,7 +833,7 @@ func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
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)) module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/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)
@@ -777,7 +867,7 @@ func buildGit(t *testing.T, directory string, args ...string) string {
func TestBuildSourceEligibilitySurvivesLaterCheckoutCleanup(t *testing.T) { func TestBuildSourceEligibilitySurvivesLaterCheckoutCleanup(t *testing.T) {
const source = `package main const source = `package main
import("fmt";"os";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") import("fmt";"os";"go.maximumdirect.net/releasekit/buildinfo")
func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} } func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} }
` `
for _, mode := range []string{"clean", "dirty before build", "changed during build", "commit during build"} { for _, mode := range []string{"clean", "dirty before build", "changed during build", "commit during build"} {
@@ -815,7 +905,7 @@ func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",b
} }
buildGit(t, repository, "restore", "cmd/app/main.go") buildGit(t, repository, "restore", "cmd/app/main.go")
buildGit(t, repository, "checkout", "--detach", "v1.2.3") buildGit(t, repository, "checkout", "--detach", "v1.2.3")
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Prebuilt: &result}) delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea}, Prebuilt: &result})
if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted { if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted {
t.Fatalf("dirty source delivered: %#v %v", delivered, err) t.Fatalf("dirty source delivered: %#v %v", delivered, err)
} }
@@ -838,7 +928,7 @@ func TestCandidateBuildDoesNotRequireWritableRepositoryParent(t *testing.T) {
t.Skip("requires ordinary Unix permission checks") t.Skip("requires ordinary Unix permission checks")
} }
repository := newBuildConsumer(t, `package main repository := newBuildConsumer(t, `package main
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") import("fmt";"go.maximumdirect.net/releasekit/buildinfo")
func main(){fmt.Println("fixture",buildinfo.Version())} func main(){fmt.Println("fixture",buildinfo.Version())}
`) `)
writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n") writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n")

View File

@@ -2,7 +2,7 @@
// built with ReleaseKit. // built with ReleaseKit.
package buildinfo package buildinfo
import "gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo" import "go.maximumdirect.net/releasekit/internal/runtimeinfo"
// Version returns the injected release identifier when available. Otherwise, // Version returns the injected release identifier when available. Otherwise,
// it returns the executable's main module version, or "dev" when that metadata // it returns the executable's main module version, or "dev" when that metadata

View File

@@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "go.maximumdirect.net/releasekit/internal/versioncontract"
) )
func TestVersionInConsumerExecutable(t *testing.T) { func TestVersionInConsumerExecutable(t *testing.T) {
@@ -21,9 +21,9 @@ func TestVersionInConsumerExecutable(t *testing.T) {
go 1.26.0 go 1.26.0
require gitea.maximumdirect.net/eric/ReleaseKit v0.0.0 require go.maximumdirect.net/releasekit v0.0.0
replace gitea.maximumdirect.net/eric/ReleaseKit => %s replace go.maximumdirect.net/releasekit => %s
`, filepath.ToSlash(repo)) `, filepath.ToSlash(repo))
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil { if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -32,7 +32,7 @@ replace gitea.maximumdirect.net/eric/ReleaseKit => %s
import ( import (
"fmt" "fmt"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { fmt.Print(buildinfo.Version()) } func main() { fmt.Print(buildinfo.Version()) }

View File

@@ -4,10 +4,10 @@ import (
"context" "context"
"io" "io"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "go.maximumdirect.net/releasekit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish" "go.maximumdirect.net/releasekit/internal/publish"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" "go.maximumdirect.net/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.
@@ -46,6 +46,11 @@ type ReleaseRequest struct {
// this string exactly before validation or tag creation. Check ignores this // this string exactly before validation or tag creation. Check ignores this
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL. // publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
ExpectedPushURL string ExpectedPushURL string
// PublicationRecordDir optionally overrides Publish's private attempt-record
// directory. Relative paths are resolved from the source repository root;
// Check ignores this publication-only setting. Recording failure is returned
// separately on PublicationResult and does not change publication status.
PublicationRecordDir string
// Validation selects optional checks run against the captured candidate. // Validation selects optional checks run against the captured candidate.
Validation ValidationSpec Validation ValidationSpec
// AdditionalChecks optionally runs product checks after shared validation. // AdditionalChecks optionally runs product checks after shared validation.
@@ -59,6 +64,8 @@ type ReleaseRequest struct {
// module and shared-file inputs. Independent nested modules form automatic // module and shared-file inputs. Independent nested modules form automatic
// input boundaries; vendored files remain included. Referenced destinations // input boundaries; vendored files remain included. Referenced destinations
// remain available for targeted inspection without expanding the input set. // remain available for targeted inspection without expanding the input set.
// ForbiddenFiles checks explicitly selected repository paths independently of
// those file inputs.
type ValidationSpec struct { type ValidationSpec struct {
// SharedFiles adds repository-relative committed files to optional file // SharedFiles adds repository-relative committed files to optional file
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and // checks. Nil selects the optional root COPYRIGHT, LICENSE, and
@@ -66,7 +73,11 @@ type ValidationSpec struct {
// Explicit files replace the defaults and must exist even when file checks // Explicit files replace the defaults and must exist even when file checks
// are off. // are off.
SharedFiles []string SharedFiles []string
Go GoValidationSpec // ForbiddenFiles lists literal repository-relative file paths that must be
// absent from the captured commit, independently of module/shared inputs.
// An empty list disables the check. File contents and history are not read.
ForbiddenFiles []string
Go GoValidationSpec
// GoFormat checks the formatting of committed regular Go source files. It // GoFormat checks the formatting of committed regular Go source files. It
// does not modify repository contents and is disabled when false. // does not modify repository contents and is disabled when false.
GoFormat bool GoFormat bool
@@ -137,6 +148,8 @@ type GoValidationSpec struct {
// of test-time vet, and Validation.Go.CompileTargets compiles all packages for // 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 // each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
// compilation produces no artifacts and does not execute target binaries. // compilation produces no artifacts and does not execute target binaries.
// Validation.ForbiddenFiles checks that selected repository-relative files are
// absent from the captured commit after Go checks and before other file checks.
// Validation.GoFormat checks regular committed Go source after those selected // Validation.GoFormat checks regular committed Go source after those selected
// Go checks and before AdditionalChecks. Its zero value disables formatting. // Go checks and before AdditionalChecks. Its zero value disables formatting.
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace // Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
@@ -189,6 +202,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
FullVet: request.Validation.Go.FullVet, FullVet: request.Validation.Go.FullVet,
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)), CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
SharedFiles: copyStringSlice(request.Validation.SharedFiles), SharedFiles: copyStringSlice(request.Validation.SharedFiles),
ForbiddenFiles: copyStringSlice(request.Validation.ForbiddenFiles),
GoFormat: request.Validation.GoFormat, GoFormat: request.Validation.GoFormat,
Markdown: internalMarkdownSettings(request.Validation.Markdown), Markdown: internalMarkdownSettings(request.Validation.Markdown),
ModuleDistribution: validation.ModuleDistributionSettings{ ModuleDistribution: validation.ModuleDistributionSettings{

View File

@@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
) )
func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) { func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
@@ -42,7 +42,7 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
defer server.Close() defer server.Close()
settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module, settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module,
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
} }
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
cliGitRun(t, repository, "add", ".") cliGitRun(t, repository, "add", ".")
@@ -94,9 +94,9 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil { if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
requestsBefore := state.snapshot().apiRequests writesBefore := state.snapshot().writes
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore { if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().writes != writesBefore {
t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String()) t.Fatalf("changed content status=%d, writes=%d->%d, stderr=%s", status, writesBefore, state.snapshot().writes, stderr.String())
} }
}) })
} }

View File

@@ -0,0 +1,60 @@
package main
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
)
func TestCIDeliveryRejectsModuleTagMismatchBeforePreparation(t *testing.T) {
for _, binary := range []bool{false, true} {
name := "page-only"
if binary {
name = "binary"
}
t.Run(name, func(t *testing.T) {
repository := newDeliveryBuildRepository(t)
writeRepositoryFile(t, repository, "child/go.mod", "module example.com/tagged-app/child\n\ngo 1.26.0\n")
writeRepositoryFile(t, repository, "child/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nChild release.\n")
cliGitRun(t, repository, "add", "child")
cliGitRun(t, repository, "commit", "-m", "add child module")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("mismatched module tag reached forge: %s", r.URL.Path)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
defer server.Close()
settings := cli.Settings{
Product: "Example Product", ModuleDir: "child",
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}
if binary {
settings.Build = &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
}
writeDeliveryProfile(t, repository, settings)
commit := pushTaggedCLIRepository(t, repository)
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
shimDir := t.TempDir()
marker := filepath.Join(shimDir, "go-invoked")
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_GO_MARKER", marker)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--prepared", "--commit", commit, "--expected-module", "child", "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), `expected delivery tag "v1.2.3"`) || !strings.Contains(stderr.String(), `selected module tag "child/v1.2.3"`) {
t.Fatalf("status=%d stderr=%q, want module tag mismatch", status, stderr.String())
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("dependency/build work ran before tag comparison: %v", err)
}
})
}
}

View File

@@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
) )
func TestCandidateCheckUsesCommittedDeclarationSymlink(t *testing.T) { func TestCandidateCheckUsesCommittedDeclarationSymlink(t *testing.T) {
@@ -40,7 +40,7 @@ func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) {
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module, writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module,
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
state.update(func(state *deliveryServerData) { 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", state.snapshot().tag) cliGitRun(t, repository, "tag", state.snapshot().tag)

View File

@@ -0,0 +1,71 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
)
func TestDeliveryFailureStatusesRespectPhase(t *testing.T) {
for _, test := range []struct {
name, rejectedPath, state string
cancelWrite bool
status int
}{
{name: "preflight authorization", rejectedPath: "/api/v1/repos/owner/example", state: "not_attempted", status: 2},
{name: "later authorization", rejectedPath: "/api/v1/repos/owner/example/tags/v1.2.3", state: "not_attempted", status: 1},
{name: "canceled unresolved write", cancelWrite: true, state: "unknown", status: 3},
} {
t.Run(test.name, func(t *testing.T) {
repository := newCLIRepository(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var commit string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == test.rejectedPath {
http.Error(w, "rejected credential", http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
case "/api/v1/repos/owner/example/tags/v1.2.3":
_ = json.NewEncoder(w).Encode(map[string]any{"commit": map[string]string{"sha": commit}})
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
http.NotFound(w, r)
case "/api/v1/repos/owner/example/releases":
if r.Method != http.MethodPost || !test.cancelWrite {
t.Errorf("unexpected release write: %s", r.Method)
}
cancel()
http.Error(w, "unacknowledged write", http.StatusServiceUnavailable)
default:
t.Errorf("unexpected request: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
})
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
status := run(ctx, []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != test.status || !strings.Contains(stderr.String(), "state="+test.state) {
t.Fatalf("status=%d stderr=%q, want status %d and state %s", status, stderr.String(), test.status, test.state)
}
if test.cancelWrite && !strings.Contains(stderr.String(), context.Canceled.Error()) {
t.Fatalf("cancellation cause missing: %s", stderr.String())
}
})
}
}

View File

@@ -0,0 +1,64 @@
package main
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
)
func TestInvalidDeliveryDestinationStopsBeforePreparation(t *testing.T) {
for _, command := range []string{"deliver", "ci-deliver"} {
for _, setting := range []string{"URL", "owner", "repository"} {
t.Run(command+"/"+setting, func(t *testing.T) {
repository := newDeliveryBuildRepository(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("invalid destination made a hosted request: %s", r.URL.Path)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
defer server.Close()
destination := &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}
switch setting {
case "URL":
destination.BaseURL = "ssh://forge.example"
case "owner":
destination.Owner = "owner/child"
case "repository":
destination.Repository = "repo/child"
}
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product", Delivery: destination,
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
})
commit := pushTaggedCLIRepository(t, repository)
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
shimDir := t.TempDir()
marker := filepath.Join(shimDir, "go-invoked")
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_GO_MARKER", marker)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
args := []string{command, "--repo", repository}
if command == "ci-deliver" {
args = append(args, "--prepared", "--commit", commit)
}
args = append(args, "v1.2.3")
var stdout, stderr bytes.Buffer
status := run(context.Background(), args, &stdout, &stderr)
if status != 2 || !strings.Contains(stderr.String(), "invalid_destination") {
t.Fatalf("status=%d stderr=%q, want destination configuration error", status, stderr.String())
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("preparation/build ran for invalid destination: %v", err)
}
})
}
}
}

View File

@@ -12,7 +12,7 @@ func TestMultiModuleExampleSetupUsesCurrentSourceTool(t *testing.T) {
root := cliRepositoryRoot(t) root := cliRepositoryRoot(t)
repository := newCLIRepository(t) repository := newCLIRepository(t)
copyExampleTree(t, filepath.Join(root, "examples", "multi-module"), repository) copyExampleTree(t, filepath.Join(root, "examples", "multi-module"), repository)
const module = "gitea.maximumdirect.net/eric/ReleaseKit" const module = "go.maximumdirect.net/releasekit"
environment := append(os.Environ(), "GOTOOLCHAIN=local", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOFLAGS=") environment := append(os.Environ(), "GOTOOLCHAIN=local", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOFLAGS=")
goCommand := func(directory string, args ...string) string { goCommand := func(directory string, args ...string) string {
t.Helper() t.Helper()

View File

@@ -13,21 +13,23 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "go.maximumdirect.net/releasekit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "go.maximumdirect.net/releasekit/internal/versioncontract"
) )
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" const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--record-dir DIR] [--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\n releasekit reconcile --record FILE [--json]\n releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]"
type Command string type Command string
const ( const (
VersionCommand Command = "version" VersionCommand Command = "version"
CheckAction Command = "check" CheckAction Command = "check"
PublishAction Command = "publish" PublishAction Command = "publish"
BuildAction Command = "build" BuildAction Command = "build"
DeliveryAction Command = "deliver" DeliveryAction Command = "deliver"
CIDeliveryAction Command = "ci-deliver" CIDeliveryAction Command = "ci-deliver"
ReconcileAction Command = "reconcile"
PublicationAttemptsAction Command = "publication-attempts"
) )
// Invocation contains parsed command settings owned by the CLI adapter. // Invocation contains parsed command settings owned by the CLI adapter.
@@ -43,6 +45,9 @@ type Invocation struct {
ExpectedModule string ExpectedModule string
ExpectedTag string ExpectedTag string
Prepared bool Prepared bool
RecordPath string
RecordDir string
JSON bool
} }
// Parse accepts only commands that have been implemented by the executable. // Parse accepts only commands that have been implemented by the executable.
@@ -51,6 +56,30 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("a command is required") return Invocation{}, errors.New("a command is required")
} }
switch args[0] { switch args[0] {
case string(ReconcileAction), string(PublicationAttemptsAction):
action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard)
record := ""
recordDir := ""
repository := "."
if action == ReconcileAction {
flags.StringVar(&record, "record", "", "attempt record file")
} else {
flags.StringVar(&repository, "repo", ".", "repository directory")
flags.StringVar(&recordDir, "record-dir", "", "attempt-record directory")
}
structured := flags.Bool("json", false, "write structured JSON")
if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err
}
if flags.NArg() != 0 {
return Invocation{}, fmt.Errorf("%s takes no positional arguments", action)
}
if action == ReconcileAction && record == "" {
return Invocation{}, errors.New("reconcile requires --record FILE")
}
return Invocation{Command: action, Repository: repository, RecordPath: record, RecordDir: recordDir, JSON: *structured}, nil
case string(VersionCommand): case string(VersionCommand):
if len(args) != 1 { if len(args) != 1 {
return Invocation{}, errors.New("version takes no arguments") return Invocation{}, errors.New("version takes no arguments")
@@ -75,6 +104,10 @@ func Parse(args []string) (Invocation, error) {
if action == PublishAction || action == CIDeliveryAction { if action == PublishAction || action == CIDeliveryAction {
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping") flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
} }
recordDir := ""
if action == PublishAction {
flags.StringVar(&recordDir, "record-dir", "", "publication attempt-record directory")
}
expectedCommit := "" expectedCommit := ""
prepared := false prepared := false
if action == CIDeliveryAction { if action == CIDeliveryAction {
@@ -112,7 +145,7 @@ func Parse(args []string) (Invocation, error) {
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: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared}, 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, RecordDir: recordDir}, nil
default: default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0]) return Invocation{}, fmt.Errorf("unknown command %q", args[0])
} }
@@ -153,8 +186,9 @@ type Settings struct {
type ValidationSettings struct { type ValidationSettings struct {
// SharedFiles selects committed files relative to the repository root. // SharedFiles selects committed files relative to the repository root.
// Nil uses optional defaults; a non-nil empty slice selects no shared files. // Nil uses optional defaults; a non-nil empty slice selects no shared files.
SharedFiles []string `json:"shared_files"` SharedFiles []string `json:"shared_files"`
Go GoValidationSettings `json:"go,omitempty"` ForbiddenFiles []string `json:"forbidden_files,omitempty"`
Go GoValidationSettings `json:"go,omitempty"`
// GoFormat checks the formatting of committed regular Go source files. // GoFormat checks the formatting of committed regular Go source files.
// Omitted or false disables the check. // Omitted or false disables the check.
GoFormat bool `json:"go_format,omitempty"` GoFormat bool `json:"go_format,omitempty"`
@@ -195,12 +229,14 @@ type GoValidationSettings struct {
CompileTargets []string `json:"compile_targets,omitempty"` CompileTargets []string `json:"compile_targets,omitempty"`
} }
// DeliverySettings selects the hosted Gitea release destination. // DeliverySettings selects the hosted forge release destination.
type DeliverySettings struct { type DeliverySettings struct {
Forge string `json:"forge,omitempty"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
Owner string `json:"owner"` Owner string `json:"owner"`
Repository string `json:"repository"` Repository string `json:"repository"`
TokenEnv string `json:"token_env,omitempty"` TokenEnv string `json:"token_env,omitempty"`
TokenFile string `json:"token_file,omitempty"`
} }
// BuildSettings selects the Go application package, artifact name, and targets. // BuildSettings selects the Go application package, artifact name, and targets.

View File

@@ -19,6 +19,33 @@ func TestParse(t *testing.T) {
if invocation.Command != VersionCommand { if invocation.Command != VersionCommand {
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand) t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
} }
reconcile, err := Parse([]string{"reconcile", "--record", "records/release record's.json", "--json"})
if err != nil {
t.Fatal(err)
}
if reconcile.Command != ReconcileAction || reconcile.RecordPath != "records/release record's.json" || !reconcile.JSON {
t.Fatalf("Parse(reconcile) = %#v", reconcile)
}
attempts, err := Parse([]string{"publication-attempts", "--repo", "repository with spaces", "--record-dir", "records with spaces", "--json"})
if err != nil {
t.Fatal(err)
}
if attempts.Command != PublicationAttemptsAction || attempts.Repository != "repository with spaces" || attempts.RecordDir != "records with spaces" || !attempts.JSON {
t.Fatalf("Parse(publication-attempts) = %#v", attempts)
}
for _, args := range [][]string{
{"reconcile"},
{"reconcile", "record.json"},
{"reconcile", "--record", "record.json", "--repo", "."},
{"reconcile", "--record", "record.json", "--record-dir", "records"},
{"reconcile", "--record", "record.json", "VERSION"},
{"publication-attempts", "--record", "record.json"},
{"publication-attempts", "VERSION"},
} {
if _, err := Parse(args); err == nil {
t.Errorf("Parse(%q) succeeded, want a usage error", args)
}
}
check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"}) check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -26,13 +53,16 @@ func TestParse(t *testing.T) {
if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" { if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" {
t.Fatalf("Parse(check) = %#v", check) t.Fatalf("Parse(check) = %#v", check)
} }
publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "v1.2.4"}) publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "--record-dir", "attempt records", "v1.2.4"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
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.RecordDir != "attempt records" || publish.Version != "v1.2.4" {
t.Fatalf("Parse(publish) = %#v", publish) t.Fatalf("Parse(publish) = %#v", publish)
} }
if _, err := Parse([]string{"check", "--record-dir", "attempt records", "v1.2.4"}); err == nil {
t.Fatal("Parse(check) accepted the publication-only record directory")
}
publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"}) publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"})
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" { if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err) t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)

View File

@@ -10,9 +10,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "go.maximumdirect.net/releasekit/internal/versioncontract"
) )
const BuildReceiptName = ".releasekit-build.json" const BuildReceiptName = ".releasekit-build.json"
@@ -49,6 +49,10 @@ type BuildReceipt struct {
} }
func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) { func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
return writeBuildReceipt(outputDir, receipt, os.Remove)
}
func writeBuildReceipt(outputDir string, receipt BuildReceipt, remove func(string) error) (resultPath string, resultErr error) {
resolvedDir, err := filepath.Abs(outputDir) resolvedDir, err := filepath.Abs(outputDir)
if err != nil { if err != nil {
return "", fmt.Errorf("resolve build receipt directory: %w", err) return "", fmt.Errorf("resolve build receipt directory: %w", err)
@@ -67,7 +71,11 @@ func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
return "", fmt.Errorf("create temporary build receipt: %w", err) return "", fmt.Errorf("create temporary build receipt: %w", err)
} }
temporaryPath := temporary.Name() temporaryPath := temporary.Name()
defer os.Remove(temporaryPath) defer func() {
if err := remove(temporaryPath); err != nil && !errors.Is(err, os.ErrNotExist) {
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary build receipt %q: %w", temporaryPath, err))
}
}()
if _, err := temporary.Write(contents); err != nil { if _, err := temporary.Write(contents); err != nil {
_ = temporary.Close() _ = temporary.Close()
return "", fmt.Errorf("write build receipt: %w", err) return "", fmt.Errorf("write build receipt: %w", err)
@@ -182,32 +190,15 @@ func validateBuildReceipt(receipt BuildReceipt) error {
if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) { if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) {
return errors.New("build receipt target and artifact counts do not match") return errors.New("build receipt target and artifact counts do not match")
} }
seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets)) declarations := make([]artifact.Declaration, 0, len(receipt.Artifacts))
seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts)) for _, item := range receipt.Artifacts {
targets := make([]artifact.Target, 0, len(receipt.Targets)) declarations = append(declarations, artifact.Declaration{Target: artifact.Target{OS: item.Target.OS, Arch: item.Target.Arch}, Filename: item.Filename})
for _, target := range receipt.Targets {
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
return fmt.Errorf("build receipt has unsafe target %q/%q", target.OS, target.Arch)
}
if seenTargets[target] {
return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch)
}
seenTargets[target] = true
targets = append(targets, artifact.Target{OS: target.OS, Arch: target.Arch})
} }
declarations := artifact.ExpectedBuild(receipt.BinaryName, receipt.Version, targets) if err := artifact.ValidateDeclarations(receipt.BinaryName, receipt.Version, parseStoredTargets(receipt.Targets), declarations); err != nil {
wantByTarget := make(map[ReceiptTarget]string, len(declarations)) return fmt.Errorf("build receipt: %w", err)
for _, declaration := range declarations {
target := ReceiptTarget{OS: declaration.Target.OS, Arch: declaration.Target.Arch}
wantByTarget[target] = declaration.Filename
} }
for _, artifact := range receipt.Artifacts { for _, artifact := range receipt.Artifacts {
if !seenTargets[artifact.Target] || seenArtifacts[artifact.Target] { if filepath.Base(artifact.Filename) != artifact.Filename {
return fmt.Errorf("build receipt has an unexpected artifact target %s/%s", artifact.Target.OS, artifact.Target.Arch)
}
seenArtifacts[artifact.Target] = true
wantName := wantByTarget[artifact.Target]
if artifact.Filename != wantName || filepath.Base(artifact.Filename) != artifact.Filename {
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename) return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
} }
wantPath := filepath.Join(receipt.OutputDir, artifact.Filename) wantPath := filepath.Join(receipt.OutputDir, artifact.Filename)

View File

@@ -4,12 +4,68 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
) )
func TestWriteBuildReceiptReportsTemporaryCleanup(t *testing.T) {
for _, mode := range []string{"renamed", "cleanup failure", "install and cleanup failure"} {
t.Run(mode, func(t *testing.T) {
repository := t.TempDir()
output := t.TempDir()
name := "app-v1.2.3-linux-amd64"
receipt := BuildReceipt{
SchemaVersion: BuildReceiptSchemaVersion, RepositoryDir: repository, ModuleDir: repository,
ModulePath: "example.com/receipt", Tag: "v1.2.3", CandidateCommit: strings.Repeat("a", 40),
Version: "v1.2.3", MainPackage: ".", BinaryName: "app", Compiler: "go fixture",
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
Artifacts: []ReceiptArtifact{{Path: filepath.Join(output, name), Filename: name, Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: strings.Repeat("a", 64)}},
}
if mode == "install and cleanup failure" {
blocked := filepath.Join(output, BuildReceiptName)
if err := os.Mkdir(blocked, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(blocked, "keep"), nil, 0o600); err != nil {
t.Fatal(err)
}
}
cleanupErr := errors.New("cleanup failed")
var temporaryPath string
remove := func(path string) error {
temporaryPath = path
err := os.Remove(path)
if mode == "renamed" {
return err
}
return cleanupErr
}
path, err := writeBuildReceipt(output, receipt, remove)
if mode == "renamed" {
if err != nil || path != filepath.Join(output, BuildReceiptName) {
t.Fatalf("renamed receipt = %q, %v", path, err)
}
return
}
if !errors.Is(err, cleanupErr) || temporaryPath == "" || !strings.Contains(err.Error(), temporaryPath) {
t.Fatalf("cleanup path = %q, error = %v", temporaryPath, err)
}
if mode == "cleanup failure" && path != filepath.Join(output, BuildReceiptName) {
t.Fatalf("installed receipt path = %q", path)
}
if mode == "install and cleanup failure" {
var pathErr *os.PathError
if !errors.As(err, &pathErr) {
t.Fatalf("install cause missing: %v", err)
}
}
})
}
}
func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) { func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string

View File

@@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
) )
func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T) { func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T) {
@@ -20,7 +20,7 @@ func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T)
defer server.Close() defer server.Close()
settings := cli.Settings{Product: "Example Product", settings := cli.Settings{Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
} }
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
for _, args := range [][]string{ for _, args := range [][]string{

View File

@@ -10,13 +10,15 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"syscall" "syscall"
"time"
"gitea.maximumdirect.net/eric/ReleaseKit" "go.maximumdirect.net/releasekit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "go.maximumdirect.net/releasekit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/gotool"
"go.maximumdirect.net/releasekit/internal/releaseidentity"
) )
func main() { func main() {
@@ -32,6 +34,9 @@ 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.ReconcileAction || invocation.Command == cli.PublicationAttemptsAction {
return runRecovery(ctx, invocation, stdout, stderr)
}
if invocation.Command != cli.VersionCommand { if invocation.Command != cli.VersionCommand {
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository) invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
if err != nil { if err != nil {
@@ -45,102 +50,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version()) fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
return 0 return 0
case cli.CheckAction, cli.PublishAction: case cli.CheckAction, cli.PublishAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) return runCheckOrPublish(ctx, invocation, stdout, stderr)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
request := candidateRequest(invocation, settings, stderr)
if invocation.Command == cli.PublishAction {
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)
if err != nil {
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) {
return 2
}
if result.State == releasekit.PublicationUnknown {
return 3
}
return 1
}
fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
return 0
}
candidate, err := releasekit.Check(ctx, request)
if err != nil {
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) {
return 2
}
return 1
}
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
case cli.BuildAction: case cli.BuildAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) return runBuild(ctx, invocation, stdout, stderr)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
if settings.Build == nil {
fmt.Fprintln(stderr, "build requires build settings in the profile")
return 2
}
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir,
Output: stderr,
})
if result.OutputDir != "" && len(result.Targets) != 0 {
if receiptErr := cli.RemoveBuildReceipt(result.OutputDir); receiptErr != nil {
fmt.Fprintf(stderr, "%v\n", receiptErr)
return 1
}
}
if err != nil {
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 {
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
}
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
if len(result.Targets) == 0 {
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
}
receipt := receiptFromBuild(result)
receiptPath, err := cli.WriteBuildReceipt(result.OutputDir, receipt)
if err != nil {
fmt.Fprintf(stderr, "write build receipt: %v\n", err)
return 1
}
stored, err := cli.LoadBuildReceipt(receiptPath)
if err != nil {
fmt.Fprintf(stderr, "verify build receipt: %v\n", err)
return 1
}
if !stored.MatchesBuild(result.RepositoryDir, result.CandidateCommit, result.Version, *settings.Build) {
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
return 1
}
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 {
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
}
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
return 0
case cli.DeliveryAction: case cli.DeliveryAction:
return runDelivery(ctx, invocation, stdout, stderr) return runDelivery(ctx, invocation, stdout, stderr)
case cli.CIDeliveryAction: case cli.CIDeliveryAction:
@@ -149,6 +61,196 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 2 return 2
} }
func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
request := candidateRequest(invocation, settings, stderr)
if invocation.Command == cli.PublishAction {
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
request.PublicationRecordDir = invocation.RecordDir
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
result, err := releasekit.Publish(ctx, request)
reportPublicationRecord(ctx, result, err, stderr)
if err != nil {
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, git.DisplayURL(result.Endpoint), candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
if result.State == releasekit.PublicationUnknown {
return 3
}
return 1
}
fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
return 0
}
candidate, err := releasekit.Check(ctx, request)
if err != nil {
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) {
return 2
}
return 1
}
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
}
func reportPublicationRecord(ctx context.Context, result releasekit.PublicationResult, publicationErr error, stderr io.Writer) {
if result.RecordError != nil {
fmt.Fprintf(stderr, "publication record warning: %s\n", git.DisplayError(result.RecordError))
}
if result.RecordPath == "" {
return
}
fmt.Fprintf(stderr, "publication record saved: %s\n", git.DisplayDiagnostic(result.RecordPath))
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
return
}
// Handoff preparation follows the failed publication and has its own bounded
// lifetime, including when publication's context was canceled.
handoffContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
command, err := selectedPublicationRecoveryCommand(handoffContext, result)
if err != nil {
fmt.Fprintf(stderr, "recovery tool preparation warning: %s\n", git.DisplayError(err))
command, err = publicationRecoveryCommand(result.RecordPath)
if err != nil {
fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err))
fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.")
return
}
}
fmt.Fprintf(stderr, "recovery command: %s\n", git.DisplayDiagnostic(command))
fmt.Fprintln(stderr, "If this entry point is unavailable, use your selected supported ReleaseKit CLI with the same record.")
}
func selectedPublicationRecoveryCommand(ctx context.Context, result releasekit.PublicationResult) (string, error) {
if result.Evidence.ModuleDir != "" {
common, err := git.CommonDirectory(ctx, result.RepositoryDir)
if err != nil {
return "", err
}
args, err := gotool.RecoveryTool(ctx, result.Evidence.ModuleDir, filepath.Join(common, "releasekit", "publication-recovery"))
if err != nil {
return "", err
}
if len(args) > 0 {
command := "env GOWORK=off GOTOOLCHAIN=local go"
for _, arg := range args {
command += " " + shellQuote(git.DisplayDiagnostic(arg))
}
path, err := filepath.Abs(git.DisplayDiagnostic(result.RecordPath))
if err != nil {
return "", err
}
return command + " reconcile --record " + shellQuote(path), nil
}
}
return publicationRecoveryCommand(result.RecordPath)
}
func publicationOffersRecovery(phase releasekit.PublicationPhase) bool {
switch phase {
case releasekit.PublicationTagCreation, releasekit.PublicationPush, releasekit.PublicationReconciliation:
return true
default:
return false
}
}
func publicationRecoveryCommand(recordPath string) (string, error) {
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("find current executable: %w", err)
}
executable, err = filepath.Abs(executable)
if err != nil {
return "", fmt.Errorf("resolve current executable path: %w", err)
}
if gotool.TemporaryExecutable(executable) {
return "", fmt.Errorf("current executable belongs to a temporary Go build; select a supported ReleaseKit CLI for the saved record")
}
// Redact before path cleaning can change a pasted URL's recognizable syntax.
recordPath, err = filepath.Abs(git.DisplayDiagnostic(recordPath))
if err != nil {
return "", fmt.Errorf("resolve publication record path: %w", err)
}
return shellQuote(executable) + " reconcile --record " + shellQuote(recordPath), nil
}
func shellQuote(argument string) string {
return "'" + strings.ReplaceAll(argument, "'", "'\"'\"'") + "'"
}
func runBuild(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
if settings.Build == nil {
fmt.Fprintln(stderr, "build requires build settings in the profile")
return 2
}
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir,
Output: stderr,
})
if result.OutputDir != "" && len(result.Targets) != 0 {
if receiptErr := cli.RemoveBuildReceipt(result.OutputDir); receiptErr != nil {
fmt.Fprintf(stderr, "%v\n", receiptErr)
return 1
}
}
if err != nil {
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 {
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
}
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
if len(result.Targets) == 0 {
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
}
receipt := receiptFromBuild(result)
receiptPath, err := cli.WriteBuildReceipt(result.OutputDir, receipt)
if err != nil {
fmt.Fprintf(stderr, "write build receipt: %v\n", err)
return 1
}
stored, err := cli.LoadBuildReceipt(receiptPath)
if err != nil {
fmt.Fprintf(stderr, "verify build receipt: %v\n", err)
return 1
}
if !stored.MatchesBuild(result.RepositoryDir, result.CandidateCommit, result.Version, *settings.Build) {
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
return 1
}
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 {
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
}
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
return 0
}
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest { func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
request := releasekit.ReleaseRequest{ request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
@@ -181,8 +283,9 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
return releasekit.ValidationSpec{} return releasekit.ValidationSpec{}
} }
result := releasekit.ValidationSpec{ result := releasekit.ValidationSpec{
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles), SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
GoFormat: settings.GoFormat, ForbiddenFiles: copyStringsPreservingEmpty(settings.ForbiddenFiles),
GoFormat: settings.GoFormat,
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{ ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles, WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
LocalReplacements: settings.ModuleDistribution.LocalReplacements, LocalReplacements: settings.ModuleDistribution.LocalReplacements,
@@ -290,11 +393,24 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
fmt.Fprintf(stderr, "profile module selection: %v\n", err) fmt.Fprintf(stderr, "profile module selection: %v\n", err)
return 2 return 2
} }
destination, status := deliveryDestination(settings, stderr) identity, err := releaseidentity.FromCommitted(ctx, invocation.Repository, settings.ModuleDir, invocation.Version, invocation.Commit)
if err != nil {
fmt.Fprintf(stderr, "resolve CI release identity: %v\n", err)
return 1
}
if invocation.ExpectedTag != identity.Tag {
fmt.Fprintf(stderr, "expected delivery tag %q does not match selected module tag %q\n", invocation.ExpectedTag, identity.Tag)
return 1
}
destination, status := deliveryDestination(invocation.Repository, 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.PreflightDelivery(ctx, destination); err != nil {
fmt.Fprintf(stderr, "delivery credential preflight: %v\n", err)
return deliveryPreflightExitStatus(err)
}
if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{ if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir, ModuleDir: settings.ModuleDir,
@@ -309,32 +425,61 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
// deliverWithSettings adapts prepared CLI state to the public delivery facade. // deliverWithSettings adapts prepared CLI state to the public delivery facade.
// CI entry points can reuse it after preparing the checkout and loading its profile. // CI entry points can reuse it after preparing the checkout and loading its profile.
func deliverWithSettings(ctx context.Context, invocation cli.Invocation, settings cli.Settings, stdout, stderr io.Writer) int { func deliverWithSettings(ctx context.Context, invocation cli.Invocation, settings cli.Settings, stdout, stderr io.Writer) int {
destination, status := deliveryDestination(settings, stderr) destination, status := deliveryDestination(invocation.Repository, settings, stderr)
if status != 0 { if status != 0 {
return status return status
} }
return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr) return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr)
} }
func deliveryDestination(settings cli.Settings, stderr io.Writer) (releasekit.GiteaDestination, int) { func deliveryDestination(repositoryDir string, settings cli.Settings, stderr io.Writer) (releasekit.DeliveryDestination, int) {
delivery := settings.Delivery delivery := settings.Delivery
if delivery == nil || strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" { if delivery == nil {
fmt.Fprintln(stderr, "deliver requires an explicit delivery.forge selection in the profile")
return releasekit.DeliveryDestination{}, 2
}
switch strings.TrimSpace(delivery.Forge) {
case "":
fmt.Fprintln(stderr, "deliver requires delivery.forge in the profile")
return releasekit.DeliveryDestination{}, 2
case string(releasekit.ForgeGitea):
default:
fmt.Fprintf(stderr, "unsupported delivery forge %q\n", delivery.Forge)
return releasekit.DeliveryDestination{}, 2
}
if strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" {
fmt.Fprintln(stderr, "deliver requires base_url, owner, and repository in the profile delivery settings") fmt.Fprintln(stderr, "deliver requires base_url, owner, and repository in the profile delivery settings")
return releasekit.GiteaDestination{}, 2 return releasekit.DeliveryDestination{}, 2
} }
tokenEnv := delivery.TokenEnv tokenEnv := delivery.TokenEnv
if strings.TrimSpace(tokenEnv) == "" { if strings.TrimSpace(tokenEnv) == "" {
tokenEnv = "RELEASEKIT_GITEA_TOKEN" tokenEnv = "RELEASEKIT_GITEA_TOKEN"
} }
token, ok := os.LookupEnv(tokenEnv) token, ok := os.LookupEnv(tokenEnv)
if !ok || strings.TrimSpace(token) == "" { if (!ok || strings.TrimSpace(token) == "") && delivery.TokenFile != "" {
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv) name := delivery.TokenFile
return releasekit.GiteaDestination{}, 2 if !filepath.IsAbs(name) {
name = filepath.Join(repositoryDir, name)
}
contents, err := os.ReadFile(name)
if err != nil {
fmt.Fprintf(stderr, "read delivery token file: %v\n", err)
return releasekit.DeliveryDestination{}, 2
}
token = strings.TrimSpace(string(contents))
if token == "" {
fmt.Fprintln(stderr, "delivery token file is empty or blank")
return releasekit.DeliveryDestination{}, 2
}
} }
return releasekit.GiteaDestination{BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0 if strings.TrimSpace(token) == "" {
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
return releasekit.DeliveryDestination{}, 2
}
return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
} }
func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.GiteaDestination, stdout, stderr io.Writer) int { func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.DeliveryDestination, stdout, stderr io.Writer) int {
hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0 hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0
if invocation.AssetsDir != "" && !hasTargets { if invocation.AssetsDir != "" && !hasTargets {
fmt.Fprintln(stderr, "--assets requires build targets in the profile") fmt.Fprintln(stderr, "--assets requires build targets in the profile")
@@ -383,16 +528,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
Output: stderr, Output: stderr,
}) })
if err != nil { if err != nil {
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) return reportDeliveryFailure(stderr, result, err)
writeDeliveryAssets(stderr, result)
writeDeliveryRecovery(stderr, result)
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
if result.State == releasekit.DeliveryUnknown {
return 3
}
return 1
} }
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) 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 != "" {
@@ -402,6 +538,46 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
return 0 return 0
} }
func reportDeliveryFailure(output io.Writer, result releasekit.DeliveryResult, err error) int {
if result.State == releasekit.DeliveryComplete {
fmt.Fprintln(output, "hosted delivery completed; local cleanup failed")
}
fmt.Fprintf(output, "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(output, result)
writeDeliveryRecovery(output, result)
if result.State == releasekit.DeliveryComplete {
return 1
}
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
var preflightErr *releasekit.DeliveryPreflightError
if errors.As(err, &preflightErr) {
return deliveryPreflightExitStatus(preflightErr)
}
if result.State == releasekit.DeliveryUnknown {
return 3
}
return 1
}
func deliveryPreflightExitStatus(err error) int {
var preflightErr *releasekit.DeliveryPreflightError
if !errors.As(err, &preflightErr) {
return 1
}
switch preflightErr.Kind {
case releasekit.DeliveryPreflightInvalidDestination,
releasekit.DeliveryPreflightMissingCredential,
releasekit.DeliveryPreflightInvalidCredential,
releasekit.DeliveryPreflightInsufficientPermissions,
releasekit.DeliveryPreflightInaccessibleDestination:
return 2
default:
return 1
}
}
func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) { func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
for _, group := range []struct { for _, group := range []struct {
label string label string

View File

@@ -6,6 +6,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -18,9 +19,9 @@ import (
"sync" "sync"
"testing" "testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "go.maximumdirect.net/releasekit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
) )
func TestVersionCommand(t *testing.T) { func TestVersionCommand(t *testing.T) {
@@ -36,6 +37,87 @@ func TestVersionCommand(t *testing.T) {
} }
} }
func TestCIDeliverRedactsRemoteCredentialsOnPreparationFailure(t *testing.T) {
t.Setenv("NO_PROXY", "*")
t.Setenv("no_proxy", "*")
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
t.Setenv("GIT_TERMINAL_PROMPT", "0")
repository := newCLIRepository(t)
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
remote := strings.Replace(server.URL, "http://", "http://review-user:review-password@", 1) + "/repo.git"
var stdout, stderr bytes.Buffer
status := run(t.Context(), []string{"ci-deliver", "--repo", repository, "--remote", remote, "--commit", commit, "v1.2.3"}, &stdout, &stderr)
if status != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "prepare tagged checkout") || !strings.Contains(stderr.String(), "repo.git") {
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
if strings.Contains(stderr.String(), "review-user") || strings.Contains(stderr.String(), "review-password") {
t.Fatalf("CLI preparation diagnostic exposes credentials: %q", stderr.String())
}
}
func TestPublishCommandReportsUnknownForPeeledOnlyRecovery(t *testing.T) {
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
profile, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
cliGitRun(t, repository, "add", "docs/releases/v1.2.3.md", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "configure release")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
marker := filepath.Join(t.TempDir(), "pushed")
script := `#!/bin/sh
is_push=0
is_ls=0
for arg in "$@"; do
[ "$arg" = push ] && is_push=1
[ "$arg" = ls-remote ] && is_ls=1
done
if [ "$is_ls" = 1 ] && [ -f "$PUSH_MARKER" ]; then
case " $* " in *refs/tags/v1.2.3*)
object=$("$REAL_GIT" rev-parse 'refs/tags/v1.2.3^{}')
printf '%s\trefs/tags/v1.2.3^{}\n' "$object"
exit 0;; esac
fi
if [ "$is_push" = 1 ]; then
"$REAL_GIT" "$@" || exit $?
: > "$PUSH_MARKER"
echo 'lost acknowledgment' >&2
exit 77
fi
exec "$REAL_GIT" "$@"
`
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("REAL_GIT", realGit)
t.Setenv("PUSH_MARKER", marker)
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"publish", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 3 || !strings.Contains(stderr.String(), "unknown") || strings.Contains(stdout.String(), "published") {
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
local := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != local {
t.Fatalf("remote tag = %s, want %s", got, local)
}
}
func TestUnknownCommand(t *testing.T) { func TestUnknownCommand(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 { if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 {
@@ -345,6 +427,30 @@ func TestCandidateFailurePresentationRetainsCallbackContext(t *testing.T) {
} }
} }
func TestPublishCommandRedactsCredentialsInEndpointMismatch(t *testing.T) {
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
settings := cli.Settings{Product: "Example Product", Remote: "origin", ExpectedPushURL: "https://expected-token@example.invalid/project.git"}
profile, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "configure release")
cliGitRun(t, repository, "remote", "add", "origin", "https://actual-user:actual-secret@example.invalid/project.git")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"publish", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), "unexpected publication push URL") || !strings.Contains(stderr.String(), "example.invalid/project.git") {
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
for _, secret := range []string{"expected-token", "actual-user", "actual-secret"} {
if strings.Contains(stderr.String(), secret) || strings.Contains(stdout.String(), secret) {
t.Fatalf("CLI output exposes %q: %q %q", secret, stdout.String(), stderr.String())
}
}
}
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) { func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable() raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t) repository := newCLIRepository(t)
@@ -407,6 +513,26 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) { if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) {
t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String()) t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String())
} }
const recordPrefix = "publication record saved: "
var recordPath string
for _, line := range strings.Split(stderr.String(), "\n") {
if strings.HasPrefix(line, recordPrefix) {
recordPath = strings.TrimPrefix(line, recordPrefix)
}
}
if !filepath.IsAbs(recordPath) {
t.Fatalf("default publication record path = %q, stderr = %q", recordPath, stderr.String())
}
recorded, err := releasekit.LoadPublicationEvidence(recordPath)
if err != nil {
t.Fatalf("load default publication record: %v", err)
}
if recorded.Tag != "v1.2.3" || recorded.CandidateCommit != commit || recorded.TagObject != tagObject || recorded.Endpoint != remote {
t.Fatalf("default publication evidence = %#v", recorded)
}
if strings.Contains(stderr.String(), "recovery command:") {
t.Fatalf("successful publication prescribed a recovery command: %q", stderr.String())
}
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject { if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
t.Fatalf("remote tag object = %s, want %s", got, tagObject) t.Fatalf("remote tag object = %s, want %s", got, tagObject)
} }
@@ -472,7 +598,7 @@ import (
"fmt" "fmt"
"os" "os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { func main() {
@@ -506,6 +632,65 @@ func main() {
} }
} }
func TestBuildCommandReportsOnlyHashedArtifactsAfterReadFailure(t *testing.T) {
repository := newCLIBuildRepository(t, "package main\nfunc main() {}\n")
secondArch := "arm64"
if runtime.GOARCH == secondArch {
secondArch = "amd64"
}
settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []string{runtime.GOOS + "/" + runtime.GOARCH, runtime.GOOS + "/" + secondArch}}}
profile, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
realGo, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
script := `#!/bin/sh
if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi
output=
previous=
for arg in "$@"; do
if [ "$previous" = -o ]; then output=$arg; fi
previous=$arg
done
"$RELEASEKIT_REAL_GO" "$@" || exit $?
if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then
rm "$output"
mkdir "$output"
else
: > "$RELEASEKIT_BUILD_COUNT"
fi
`
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("RELEASEKIT_REAL_GO", realGo)
t.Setenv("RELEASEKIT_BUILD_COUNT", filepath.Join(t.TempDir(), "count"))
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"build", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
first := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, runtime.GOARCH)
second := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, secondArch)
if runtime.GOOS == "windows" {
first, second = first+".exe", second+".exe"
}
content, err := os.ReadFile(filepath.Join(repository, "dist", first))
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(content)
if status != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), fmt.Sprintf("built %s %x", first, digest)) || strings.Contains(stderr.String(), "built "+second) || !strings.Contains(stderr.String(), "hash build output") || !strings.Contains(stderr.String(), second) {
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
if _, err := os.Stat(filepath.Join(repository, "dist", cli.BuildReceiptName)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("partial build left receipt: %v", err)
}
}
func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) { func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) {
repository := newCLIBuildRepository(t, `package main repository := newCLIBuildRepository(t, `package main
import ( import (
@@ -639,7 +824,7 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
settings := cli.Settings{ settings := cli.Settings{
Product: "Example Product", Product: "Example Product",
Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []string{"linux/amd64"}}}, Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []string{"linux/amd64"}}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
} }
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
@@ -673,7 +858,7 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
settings := cli.Settings{ settings := cli.Settings{
Product: "Example Product", Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
} }
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
@@ -716,7 +901,7 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t
defer server.Close() defer server.Close()
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{ writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{
Product: "Example Product", Product: "Example Product",
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
@@ -751,7 +936,7 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
writeDeliveryProfile(t, repository, cli.Settings{ writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product", Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
@@ -802,7 +987,7 @@ func TestCIDeliverRejectsExpectedModuleMismatchBeforeDependencyPreparation(t *te
Product: "Example Product", Product: "Example Product",
ModuleDir: "integrations/other", ModuleDir: "integrations/other",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
@@ -839,7 +1024,7 @@ func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) {
writeDeliveryProfile(t, repository, cli.Settings{ writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product", Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
@@ -865,7 +1050,7 @@ func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T)
writeDeliveryProfile(t, repository, cli.Settings{ writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product", Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.update(func(state *deliveryServerData) { state.commit = expectedCommit }) state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
@@ -903,7 +1088,7 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
settings := cli.Settings{ settings := cli.Settings{
Product: "Example Product", Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
} }
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") }) state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
@@ -919,6 +1104,15 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
} }
} }
func TestCompletedDeliveryCleanupFailureReportsCompletedState(t *testing.T) {
var output bytes.Buffer
result := releasekit.DeliveryResult{State: releasekit.DeliveryComplete, ModulePath: "example.com/app", Tag: "v1.2.3", ReleaseURL: "https://example.invalid/release", Uploaded: []releasekit.DeliveryAsset{{Filename: "app", SHA256: "digest"}}}
status := reportDeliveryFailure(&output, result, errors.New("remove temporary directory: failed"))
if status != 1 || !strings.Contains(output.String(), "hosted delivery completed; local cleanup failed") || !strings.Contains(output.String(), "uploaded app digest") || strings.Contains(output.String(), "retry") {
t.Fatalf("status = %d, output = %q", status, output.String())
}
}
func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) { func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
@@ -958,7 +1152,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
writeDeliveryProfile(t, repository, cli.Settings{ writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product", Product: "Example Product",
Build: &build, Build: &build,
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
@@ -1002,7 +1196,7 @@ func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) {
state := &deliveryServerState{data: deliveryServerData{remoteCommit: strings.Repeat("0", 40)}} state := &deliveryServerState{data: deliveryServerData{remoteCommit: strings.Repeat("0", 40)}}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
@@ -1018,7 +1212,7 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) {
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}})
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "") t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "")
@@ -1029,12 +1223,233 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) {
} }
} }
func TestDeliverRejectsMissingBlankAndUnsupportedForgeBeforeTokenOrBuild(t *testing.T) {
for _, forge := range []string{"", " \t", "unknown"} {
t.Run(forge, func(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{
Forge: forge, BaseURL: server.URL, Owner: "owner", Repository: "example",
TokenFile: filepath.Join(repository, "missing-token"),
},
})
state.update(func(data *deliveryServerData) { data.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3")
shimDir := t.TempDir()
markerPath := filepath.Join(shimDir, "go-invoked")
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 2 || !strings.Contains(stderr.String(), "forge") || strings.Contains(stderr.String(), "missing-token") {
t.Fatalf("deliver status=%d stdout=%q stderr=%q; want selection diagnostic before token access", status, stdout.String(), stderr.String())
}
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
t.Fatalf("Go was invoked before forge validation: %v", err)
}
if got := state.snapshot().apiRequests; got != 0 {
t.Fatalf("hosted API requests = %d, want 0", got)
}
})
}
}
func TestCIDeliverRejectsUnsupportedForgeBeforeTokenOrDependencyPreparation(t *testing.T) {
repository := newDeliveryBuildRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{
Forge: "unsupported", BaseURL: server.URL, Owner: "owner", Repository: "example",
TokenFile: filepath.Join(repository, "missing-token"),
},
})
expectedCommit := pushTaggedCLIRepository(t, repository)
state.update(func(data *deliveryServerData) { data.commit = expectedCommit })
shimDir := t.TempDir()
markerPath := filepath.Join(shimDir, "go-invoked")
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
if status != 2 || !strings.Contains(stderr.String(), "forge") || strings.Contains(stderr.String(), "missing-token") {
t.Fatalf("ci-deliver status=%d stdout=%q stderr=%q; want selection diagnostic before token access", status, stdout.String(), stderr.String())
}
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
t.Fatalf("Go was invoked before forge validation: %v", err)
}
if got := state.snapshot().apiRequests; got != 0 {
t.Fatalf("hosted API requests = %d, want 0", got)
}
}
func TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne(t *testing.T) {
repository := newCLIRepository(t)
var mu sync.Mutex
var requests, writes int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if r.Method != http.MethodGet {
writes++
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
return
}
requests++
if r.URL.Path == "/api/v1/repos/owner/example" {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1, "name": "example", "full_name": "owner/example",
"owner": map[string]string{"login": "owner"},
})
return
}
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Delivery: &cli.DeliverySettings{
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
},
})
expectedCommit := pushTaggedCLIRepository(t, repository)
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
t.Fatalf("page-only ci-deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
}
if requests != 2 || writes != 0 {
t.Fatalf("page-only preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
}
}
func TestDeliverUnavailablePreflightStopsBeforeManagedBuild(t *testing.T) {
repository := newCLIRepository(t)
var mu sync.Mutex
var requests, writes int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if r.Method != http.MethodGet {
writes++
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
return
}
requests++
if r.URL.Path == "/api/v1/repos/owner/example" {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1, "name": "example", "full_name": "owner/example",
"owner": map[string]string{"login": "owner"},
})
return
}
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
},
})
cliGitRun(t, repository, "tag", "v1.2.3")
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
shimDir := t.TempDir()
markerPath := filepath.Join(shimDir, "go-invoked")
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
t.Fatalf("deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
}
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
t.Fatalf("managed build ran before successful preflight: %v", err)
}
if requests != 2 || writes != 0 {
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
}
}
func TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation(t *testing.T) {
repository := newDeliveryBuildRepository(t)
var mu sync.Mutex
var requests, writes int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if r.Method != http.MethodGet {
writes++
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
return
}
requests++
if r.URL.Path == "/api/v1/repos/owner/example" {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1, "name": "example", "full_name": "owner/example",
"owner": map[string]string{"login": "owner"},
})
return
}
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{
Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN",
},
})
expectedCommit := pushTaggedCLIRepository(t, repository)
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "fixture-token")
shimDir := t.TempDir()
markerPath := filepath.Join(shimDir, "go-invoked")
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte("#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), "unavailable") {
t.Fatalf("ci-deliver status=%d stdout=%q stderr=%q; want unavailable status 1", status, stdout.String(), stderr.String())
}
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
t.Fatalf("dependency preparation ran before successful preflight: %v", err)
}
if requests != 2 || writes != 0 {
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
}
}
func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) { func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil {
@@ -1061,7 +1476,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
current := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} current := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
settings := cli.Settings{Product: "Example Product", Build: &current, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} settings := cli.Settings{Product: "Example Product", Build: &current, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
@@ -1097,7 +1512,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
} }
return return
} }
if status != 1 || state.snapshot().apiRequests != 0 || state.snapshot().releaseCreates != 0 || state.snapshot().assetUploads != 0 { if status != 1 || state.snapshot().writes != 0 {
t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.snapshot().apiRequests, stdout.String(), stderr.String()) t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.snapshot().apiRequests, stdout.String(), stderr.String())
} }
if mode == "tampered" || mode == "missing artifact" { if mode == "tampered" || mode == "missing artifact" {
@@ -1174,6 +1589,7 @@ type deliveryServerData struct {
product string product string
remoteCommit string remoteCommit string
apiRequests int apiRequests int
writes int
pageExists bool pageExists bool
releaseCreates int releaseCreates int
assetUploads int assetUploads int
@@ -1196,11 +1612,19 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
defer state.mu.Unlock() defer state.mu.Unlock()
if strings.HasPrefix(r.URL.Path, "/api/") { if strings.HasPrefix(r.URL.Path, "/api/") {
data.apiRequests++ data.apiRequests++
if r.Method != http.MethodGet {
data.writes++
}
if r.Header.Get("Authorization") != "token fixture-token" { if r.Header.Get("Authorization") != "token fixture-token" {
t.Errorf("authorization = %q", r.Header.Get("Authorization")) t.Errorf("authorization = %q", r.Header.Get("Authorization"))
} }
} }
switch r.URL.Path { switch r.URL.Path {
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1, "name": "example", "full_name": "owner/example",
"owner": map[string]string{"login": "owner"},
})
case "/api/v1/repos/owner/example/tags/" + data.tag: case "/api/v1/repos/owner/example/tags/" + data.tag:
if data.commit == "" { if data.commit == "" {
t.Error("tag source commit was not initialized") t.Error("tag source commit was not initialized")
@@ -1375,7 +1799,7 @@ func newCLIBuildRepository(t *testing.T, mainSource string) string {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => %s\n", filepath.ToSlash(root))
write := func(name, contents string) { write := func(name, contents string) {
t.Helper() t.Helper()
path := filepath.Join(repository, filepath.FromSlash(name)) path := filepath.Join(repository, filepath.FromSlash(name))
@@ -1445,7 +1869,7 @@ func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.T) {
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() 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"}} settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}}
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
@@ -1485,7 +1909,7 @@ func TestRecoveredPageCreatePreservesPartialDeliveryOnInspectionFailure(t *testi
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
assets, _ := writeDeliveryReceipt(t, repository, build) assets, _ := writeDeliveryReceipt(t, repository, build)
@@ -1505,7 +1929,7 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
assets, _ := writeDeliveryReceipt(t, repository, build) assets, _ := writeDeliveryReceipt(t, repository, build)
@@ -1530,7 +1954,7 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
func TestFailedRebuildInvalidatesPreviousReceipt(t *testing.T) { func TestFailedRebuildInvalidatesPreviousReceipt(t *testing.T) {
const source = `package main const source = `package main
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") import("fmt";"go.maximumdirect.net/releasekit/buildinfo")
func main(){fmt.Println("fixture",buildinfo.Version())} func main(){fmt.Println("fixture",buildinfo.Version())}
` `
repository := newCLIBuildRepository(t, source) repository := newCLIBuildRepository(t, source)
@@ -1561,7 +1985,7 @@ func TestTemporaryDeliveryBuildDoesNotRequireWritableRepositoryParent(t *testing
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, 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, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.update(func(state *deliveryServerData) { 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", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
parent := t.TempDir() parent := t.TempDir()

View File

@@ -2,11 +2,13 @@ package main
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -59,11 +61,11 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
copyExampleTree(t, example, repository) 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" rootModule := "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/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" selectedModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.198\n"
writeRepositoryFile(t, repository, "go.mod", rootModule) writeRepositoryFile(t, repository, "go.mod", rootModule)
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", selectedModule) 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" spaceModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.299\n"
writeRepositoryFile(t, repository, "integrations/ck config/go.mod", spaceModule) 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"}`) writeRepositoryFile(t, repository, "profiles/core release.json", `{"product":"Example Consumer Integration","module_dir":"integrations/ck config"}`)
cliGitRun(t, repository, "add", ".") cliGitRun(t, repository, "add", ".")
@@ -80,20 +82,13 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
set -eu set -eu
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG" printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
modfile= modfile=
output=
previous=
for argument in "$@"; do for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
if [ "$previous" = -o ]; then output=$argument; fi
previous=$argument
done done
if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi
if [ "$1" = build ]; then if [ "$1" = tool ]; then
cat > "$output" <<'TOOL' shift 3
#!/bin/sh printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
TOOL
chmod +x "$output"
fi fi
` `
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 {
@@ -134,12 +129,12 @@ fi
} }
} }
func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) { func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) {
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module") example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
repository := newCLIRepository(t) repository := newCLIRepository(t)
copyExampleTree(t, example, repository) 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, "go.mod", "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/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") writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit v0.2.198\n")
cliGitRun(t, repository, "add", ".") cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "prepare selected tool module") cliGitRun(t, repository, "commit", "-m", "prepare selected tool module")
@@ -161,27 +156,50 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3") for _, status := range []int{0, 1, 2, 3} {
output, err := command.CombinedOutput() t.Run(strconv.Itoa(status), func(t *testing.T) {
if err != nil { command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3")
t.Fatalf("launcher error: %v output=%q", err, output) command.Env = setEnvironment(os.Environ(), "RELEASEKIT_TOOL_EXIT", strconv.Itoa(status))
} output, err := command.CombinedOutput()
for _, expected := range []string{ if status == 0 && err != nil {
"tool-version=v0.2.198 helper=tool-only dependency", t.Fatalf("launcher error: %v output=%q", err, output)
`"publish"`, `"--repo"`, `"` + repository + `"`, }
`"--config"`, `"releasekit-ckconfig.json"`, if status != 0 {
`"--expected-module"`, `"integrations/ckconfig"`, `"v1.2.3"`, var exitError *exec.ExitError
} { if !errors.As(err, &exitError) || exitError.ExitCode() != status {
if !strings.Contains(string(output), expected) { t.Fatalf("launcher error = %v, want exit %d; output=%q", err, status, output)
t.Errorf("launcher output %q omits %q", output, expected) }
} }
} for _, expected := range []string{
if strings.Contains(string(output), "tool-version=v0.2.197") { "tool-version=v0.2.198 helper=tool-only dependency",
t.Fatalf("launcher built the root-selected ReleaseKit version: %s", output) `"publish"`, `"--repo"`, `"` + repository + `"`,
} `"--config"`, `"releasekit-ckconfig.json"`,
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod")) `"--expected-module"`, `"integrations/ckconfig"`, `"v1.2.3"`,
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) { } {
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err) 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 ran 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)
}
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
t.Fatalf("launcher changed source: %q", status)
}
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())
}
}
})
} }
} }

View File

@@ -15,7 +15,7 @@ import (
"sync" "sync"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
) )
func TestMultiModuleWoodpeckerDelivery(t *testing.T) { func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
@@ -51,6 +51,7 @@ func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
commit := tagAndPushWorkflowRepository(t, repository, tag) commit := tagAndPushWorkflowRepository(t, repository, tag)
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit }) delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy) 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") { 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) t.Fatalf("tagged page delivery output = %q", output)
} }
@@ -208,6 +209,11 @@ func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
apiRoot := "/api/v1/repos/owner/example/" apiRoot := "/api/v1/repos/owner/example/"
escapedTag := url.PathEscape(tag) escapedTag := url.PathEscape(tag)
switch r.URL.EscapedPath() { switch r.URL.EscapedPath() {
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1, "name": "example", "full_name": "owner/example",
"owner": map[string]string{"login": "owner"},
})
case apiRoot + "tags/" + escapedTag: case apiRoot + "tags/" + escapedTag:
_ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}}) _ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}})
case apiRoot + "releases/tags/" + escapedTag: case apiRoot + "releases/tags/" + escapedTag:
@@ -301,7 +307,7 @@ func buildWorkflowCLI(t *testing.T, root string) string {
func writeWorkflowToolProxy(t *testing.T) string { func writeWorkflowToolProxy(t *testing.T) string {
t.Helper() t.Helper()
const module = "gitea.maximumdirect.net/eric/ReleaseKit" const module = "go.maximumdirect.net/releasekit"
const helper = "example.com/releasekit-helper" const helper = "example.com/releasekit-helper"
proxyDir := t.TempDir() proxyDir := t.TempDir()
moduleFile := "module " + module + "\n\ngo 1.25.0\n\nrequire " + helper + " v1.0.0\n" moduleFile := "module " + module + "\n\ngo 1.25.0\n\nrequire " + helper + " v1.0.0\n"
@@ -330,7 +336,7 @@ func main() {
`, helper, version) `, helper, version)
files := map[string]string{ files := map[string]string{
"cmd/releasekit/main.go": main, "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", "buildinfo/version.go": "package buildinfo\nimport \"go.maximumdirect.net/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", "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, module, version, moduleFile, files)
@@ -361,11 +367,11 @@ func newWorkflowRepository(t *testing.T, example string) string {
} }
func workflowModuleFile(module, version string) string { 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) return fmt.Sprintf("module %s\n\ngo 1.25.0\n\nrequire go.maximumdirect.net/releasekit %s\n", module, version)
} }
func workflowDeliverySettings(baseURL string) *cli.DeliverySettings { func workflowDeliverySettings(baseURL string) *cli.DeliverySettings {
return &cli.DeliverySettings{BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"} return &cli.DeliverySettings{Forge: "gitea", BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"}
} }
func writeMappedProfile(t *testing.T, repository, name string, settings cli.Settings) { func writeMappedProfile(t *testing.T, repository, name string, settings cli.Settings) {
@@ -382,7 +388,7 @@ func tagAndPushWorkflowRepository(t *testing.T, repository, tag string) string {
cliGitRun(t, repository, "add", "--all") cliGitRun(t, repository, "add", "--all")
cliGitRun(t, repository, "commit", "-m", "prepare mapped delivery") cliGitRun(t, repository, "commit", "-m", "prepare mapped delivery")
commit := cliGitOutput(t, repository, "rev-parse", "HEAD") commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", tag) cliGitRun(t, repository, "tag", "-a", tag, "-m", "mapped release")
remote := filepath.Join(filepath.Dir(repository), "origin.git") remote := filepath.Join(filepath.Dir(repository), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote) cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote) cliGitRun(t, repository, "remote", "add", "origin", remote)
@@ -420,7 +426,7 @@ func prepareWorkflowModuleSum(t *testing.T, repository, moduleDir, proxy string)
return nil return nil
}) })
}) })
command := exec.Command("go", "mod", "download", "gitea.maximumdirect.net/eric/ReleaseKit@v0.2.197") command := exec.Command("go", "mod", "download", "go.maximumdirect.net/releasekit@v0.2.197")
command.Dir = filepath.Join(repository, filepath.FromSlash(moduleDir)) 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") 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 { if output, err := command.CombinedOutput(); err != nil {

View File

@@ -0,0 +1,520 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/gotool"
"go.maximumdirect.net/releasekit/internal/publish"
)
func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture executes POSIX recovery commands")
}
repo, remote := newSimplePublishRepository(t, false)
source := repositoryRoot(t)
relative, err := filepath.Rel(repo, source)
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.mod", "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => "+strconv.Quote(relative)+"\n")
writeRepositoryFile(t, repo, "uses_releasekit.go", "package candidate\nimport _ \"go.maximumdirect.net/releasekit\"\n")
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.sum", string(sums))
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
for _, args := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
command := exec.Command(goBinary, args...)
command.Dir = repo
if out, err := command.CombinedOutput(); err != nil {
t.Fatalf("prepare vendored API: %v: %s", err, out)
}
}
cliGitRun(t, repo, "add", ".")
cliGitRun(t, repo, "commit", "-m", "Vendor API dependency")
cliGitRun(t, repo, "push", "origin", "main")
before := cliGitOutput(t, repo, "rev-parse", "HEAD^{tree}")
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
launcherStore := t.TempDir()
launcherArgs, err := gotool.RecoveryTool(t.Context(), repo, launcherStore)
if err != nil {
t.Fatal(err)
}
launcher := exec.Command(goBinary, append(launcherArgs, "publish", "--repo", repo, "v1.2.3")...)
launcher.Dir = t.TempDir()
publicationOutput, err := launcher.CombinedOutput()
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("vendored publication: %v: %s", err, publicationOutput)
}
// The later handoff owns different launch metadata from the original driver.
if err := os.RemoveAll(launcherStore); err != nil {
t.Fatal(err)
}
command := outputLineValue(string(publicationOutput), "recovery command: ")
if command == "" {
t.Fatalf("no handoff: %s", publicationOutput)
}
recovery := exec.Command("sh", "-c", command)
recovery.Dir = t.TempDir()
recovered, err := recovery.CombinedOutput()
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 || !strings.Contains(string(recovered), "module=example.com/cli-candidate tag=v1.2.3 state=not_published") {
t.Fatalf("vendored recovery: %v: %s", err, recovered)
}
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("handoff changed consumer files: %s", status)
}
if after := cliGitOutput(t, repo, "write-tree"); after != before {
t.Fatalf("handoff changed consumer index: %s != %s", after, before)
}
}
func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) {
proxy := writeReleaseKitModuleProxy(t)
environment := make(map[string]string)
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))
for key, value := range environment {
t.Setenv(key, value)
}
module := t.TempDir()
data := "module example.com/recovery-consumer\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n\nreplace go.maximumdirect.net/releasekit v0.2.197 => go.maximumdirect.net/releasekit v0.2.198\n"
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil {
t.Fatal(err)
}
args, err := gotool.RecoveryTool(t.Context(), module, t.TempDir())
if err != nil {
t.Fatal(err)
}
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...)
command.Dir = t.TempDir()
output, err := command.CombinedOutput()
if err != nil || !strings.Contains(string(output), "tool-version=v0.2.198 helper=tool-only dependency") {
t.Fatalf("versioned recovery selection: %v: %s", err, output)
}
after, err := os.ReadFile(filepath.Join(module, "go.mod"))
if err != nil || string(after) != data {
t.Fatalf("consumer metadata changed: %q, %v", after, err)
}
}
func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) {
args, err := gotool.RecoveryTool(t.Context(), repositoryRoot(t), t.TempDir())
if err != nil {
t.Fatal(err)
}
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args, "version")...)
command.Dir = t.TempDir()
output, err := command.CombinedOutput()
if err != nil || !strings.HasPrefix(string(output), "releasekit ") {
t.Fatalf("source module recovery entry point: %v: %s", err, output)
}
}
func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("fixture observes the cold executable through /proc")
}
for _, mode := range []struct{ registered, canceled bool }{{true, false}, {false, false}, {true, true}, {false, true}} {
registered := mode.registered
t.Run(fmt.Sprintf("registered=%t/canceled=%t", registered, mode.canceled), func(t *testing.T) {
repo, remote := newSimplePublishRepository(t, false)
source := repositoryRoot(t)
// A distinct replacement identity gives this tool a fresh link action
// while retaining the shared compilation cache for its dependencies.
selectedSource := filepath.Join(t.TempDir(), "ReleaseKit source")
if err := os.Symlink(source, selectedSource); err != nil {
t.Fatal(err)
}
if !registered {
relative, err := filepath.Rel(repo, selectedSource)
if err != nil {
t.Fatal(err)
}
selectedSource = relative
}
module := "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => " + strconv.Quote(selectedSource) + "\n"
if registered {
module += "\ntool go.maximumdirect.net/releasekit/cmd/releasekit\n"
}
writeRepositoryFile(t, repo, "go.mod", module)
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.sum", string(sums))
if !registered {
for _, name := range []string{"release.sh", "select-module.sh"} {
data, err := os.ReadFile(filepath.Join(source, "examples", "multi-module", name))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, name), data, 0700); err != nil {
t.Fatal(err)
}
}
writeRepositoryFile(t, repo, "release-modules.tsv", "root\tv*\t.\treleasekit.json\n")
}
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
if registered {
prepare := exec.Command(goBinary, "list", "-mod=mod", "-deps", "go.maximumdirect.net/releasekit/cmd/releasekit")
prepare.Dir = repo
if out, err := prepare.CombinedOutput(); err != nil {
t.Fatalf("prepare tool: %v: %s", err, out)
}
}
cliGitRun(t, repo, "add", ".")
cliGitRun(t, repo, "commit", "-m", "Select release tool")
cliGitRun(t, repo, "push", "origin", "main")
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
executed := filepath.Join(t.TempDir(), "executed-tool")
wrapper := "#!/bin/sh\nparent=$(readlink \"/proc/$PPID/exe\")\ncase \"$parent\" in */exe/releasekit) printf '%s' \"$parent\" > " + shellQuote(executed) + ";; esac\n" + cancelPushScript + "exec " + shellQuote(realGit) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
var launch *exec.Cmd
if registered {
launch = exec.Command(goBinary, "tool", "releasekit", "publish", "--repo", repo, "v1.2.3")
} else {
launch = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3")
}
launch.Dir = repo
var out []byte
if mode.canceled {
out, err = canceledPublicationOutput(t, launch, repo, "")
} else {
out, err = launch.CombinedOutput()
}
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("publication: %v: %s", err, out)
}
text := string(out)
launchedPath, err := os.ReadFile(executed)
if err != nil {
t.Fatalf("did not exercise a cold temporary executable: %v: %s", err, text)
}
if _, err := os.Stat(string(launchedPath)); !os.IsNotExist(err) {
t.Fatalf("cold executable still exists: %s: %v", launchedPath, err)
}
entries, err := os.ReadDir(filepath.Dir(repo))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
t.Fatalf("launcher bootstrap survived: %s", entry.Name())
}
}
command := outputLineValue(text, "recovery command: ")
if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") {
t.Fatalf("unstable handoff: %s", command)
}
if !registered && !strings.Contains(command, "publication-recovery") {
t.Fatalf("API-only handoff lacks durable metadata: %s", command)
}
// Execute from another directory after the original driver and the
// API-only launcher's temporary files are gone.
evidence, err := publish.LoadEvidence(outputLineValue(text, "publication record saved: "))
if err != nil {
t.Fatal(err)
}
evidence.Endpoint = filepath.Join(t.TempDir(), "unavailable.git")
unknownRecord, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
if err != nil {
t.Fatal(err)
}
for _, scenario := range []struct {
record string
status int
}{{"", 1}, {filepath.Join(t.TempDir(), "missing.json"), 2}, {unknownRecord, 3}, {"", 0}} {
if scenario.status == 0 {
if err := os.Remove(filepath.Join(remote, "hooks", "pre-receive")); err != nil {
t.Fatal(err)
}
cliGitRun(t, repo, "push", "origin", "refs/tags/v1.2.3")
}
selected := command
if scenario.record != "" {
selected += " --record " + shellQuote(scenario.record)
}
recovery := exec.Command("sh", "-c", selected)
recovery.Dir = t.TempDir()
recovered, err := recovery.CombinedOutput()
if scenario.status == 0 && err != nil || scenario.status != 0 && (!errors.As(err, &exitError) || exitError.ExitCode() != scenario.status) {
t.Fatalf("recovery status want %d: %v: %s", scenario.status, err, recovered)
}
if scenario.status == 1 && !strings.Contains(string(recovered), "state=not_published") {
t.Fatalf("wrong observation: %s", recovered)
}
}
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("handoff modified candidate: %s", status)
}
})
}
}
func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) {
repository, _ := newSimplePublishRepository(t, true)
var stdout, stderr bytes.Buffer
status := run(t.Context(), []string{"publish", "--repo", repository, "--record-dir", "record-blocker", "v1.2.3"}, &stdout, &stderr)
if status != 0 || !strings.Contains(stdout.String(), "published v1.2.3") {
t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "publication record warning:") || strings.Contains(stderr.String(), "publication record saved:") || strings.Contains(stderr.String(), "recovery command:") {
t.Fatalf("recording warning or success handoff output = %q", stderr.String())
}
}
func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testing.T) {
for _, mode := range []string{"rejected", "canceled", "preparation failure"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_TERMINAL_PROMPT", "0")
repository, remote := newSimplePublishRepository(t, false)
store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe")
hook := filepath.Join(remote, "hooks", "pre-receive")
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
t.Fatal(err)
}
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
executable := filepath.Join(t.TempDir(), "releasekit's CLI")
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
build.Dir = repositoryRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build CLI: %v\n%s", err, output)
}
if mode == "canceled" {
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\n"+cancelPushScript+"exec "+shellQuote(realGit)+" \"$@\"\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
}
if mode == "preparation failure" {
realGo, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
script := "#!/bin/sh\nif [ \"$1\" = list ] && [ \"$2\" = -e ]; then exit 73; fi\nexec " + shellQuote(realGo) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
}
command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3")
command.Dir = repositoryRoot(t)
var output []byte
var err error
if mode == "canceled" {
output, err = canceledPublicationOutput(t, command, repository, store)
} else {
output, err = command.CombinedOutput()
}
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("publication exit = %v, output = %q; want status 1", err, output)
}
text := string(output)
if mode == "preparation failure" && !strings.Contains(text, "recovery tool preparation warning:") {
t.Fatalf("preparation warning missing: %s", text)
}
if !strings.Contains(text, "publication state=not_published phase=reconciliation") {
t.Fatalf("publication failure classification changed: %q", text)
}
recordPath := outputLineValue(text, "publication record saved: ")
if !filepath.IsAbs(recordPath) || !strings.Contains(recordPath, "attempt records with ' apostrophe") {
t.Fatalf("saved record path = %q, output = %q", recordPath, text)
}
recoveryCommand := outputLineValue(text, "recovery command: ")
if recoveryCommand == "" || !strings.Contains(recoveryCommand, shellQuote(executable)) || !strings.Contains(recoveryCommand, shellQuote(recordPath)) {
t.Fatalf("copyable recovery command = %q, output = %q", recoveryCommand, text)
}
if !strings.Contains(text, "selected supported ReleaseKit CLI") {
t.Fatalf("output omits guidance for an unavailable original executable: %q", text)
}
for _, decoy := range []publish.Evidence{
{RepositoryDir: repository, ModulePath: "example.com/other-module", Version: "v8.8.8", Tag: "v8.8.8", Endpoint: remote, Remote: "origin"},
{RepositoryDir: repository, ModulePath: "example.com/retry", Version: "v1.2.4", Tag: "v1.2.4", Endpoint: remote, Remote: "origin"},
} {
if _, err := publish.SaveEvidence(t.Context(), repository, store, decoy); err != nil {
t.Fatal(err)
}
}
recovery := exec.Command("/bin/sh", "-c", recoveryCommand)
recovery.Dir = t.TempDir()
recoveryOutput, recoveryErr := recovery.CombinedOutput()
if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, recoveryOutput)
}
recoveryText := string(recoveryOutput)
if !strings.Contains(recoveryText, "module=example.com/cli-candidate tag=v1.2.3 state=not_published") || strings.Contains(recoveryText, "example.com/other-module") || strings.Contains(recoveryText, "example.com/retry") {
t.Fatalf("printed command selected the wrong attempt: %q", recoveryText)
}
})
}
}
func TestShellQuoteHandlesApostrophesAndSpaces(t *testing.T) {
got := shellQuote("/tmp/ReleaseKit's recovery record.json")
if got != "'/tmp/ReleaseKit'\"'\"'s recovery record.json'" {
t.Fatalf("shellQuote() = %q", got)
}
}
func newSimplePublishRepository(t *testing.T, recordBlocker bool) (string, string) {
t.Helper()
repository := newCLIRepository(t)
settings, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(settings))
files := []string{"releasekit.json"}
if recordBlocker {
writeRepositoryFile(t, repository, "record-blocker", "tracked file\n")
files = append(files, "record-blocker")
}
cliGitRun(t, repository, append([]string{"add"}, files...)...)
cliGitRun(t, repository, "commit", "-m", "configure source release")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
return repository, remote
}
func outputLineValue(output, prefix string) string {
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, prefix) {
return strings.TrimPrefix(line, prefix)
}
}
return ""
}
func repositoryRoot(t *testing.T) string {
t.Helper()
directory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return filepath.Clean(filepath.Join(directory, "..", ".."))
}
// The shim blocks exactly when Git would start the recorded tag push. The CLI
// PID lets the test cancel the actual operation even behind a Go driver/launcher.
const cancelPushScript = `if [ -n "$RELEASEKIT_CANCEL_PUSH_READY" ]; then
for arg in "$@"; do
if [ "$arg" = push ]; then
printf '%s' "$PPID" > "$RELEASEKIT_CANCEL_PUSH_READY"
exec sleep 600
fi
done
fi
`
func canceledPublicationOutput(t *testing.T, command *exec.Cmd, repo, store string) ([]byte, error) {
t.Helper()
ready := filepath.Join(t.TempDir(), "push-ready")
command.Env = append(os.Environ(), "RELEASEKIT_CANCEL_PUSH_READY="+ready)
var output bytes.Buffer
command.Stdout = &output
command.Stderr = &output
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer command.Process.Kill()
done := make(chan error, 1)
go func() { done <- command.Wait() }()
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
var operation *os.Process
waiting:
for {
select {
case err := <-done:
t.Fatalf("publication finished before push interception: %v: %s", err, &output)
case <-ctx.Done():
_ = command.Process.Kill()
<-done
t.Fatal("publication never reached push interception")
case <-ticker.C:
data, err := os.ReadFile(ready)
if err != nil {
continue
}
pid, err := strconv.Atoi(string(data))
if err != nil {
continue
}
operation, err = os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
defer operation.Release()
break waiting
}
}
// Check the handoff identity before allowing cancellation to reach publication.
listed, err := publish.ListAttempts(t.Context(), repo, store)
if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].Evidence.TagObject == "" {
_ = operation.Signal(os.Interrupt)
<-done
t.Fatalf("pre-push record is incomplete: %#v, %v", listed, err)
}
if err := operation.Signal(os.Interrupt); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if !strings.Contains(output.String(), "context canceled") {
t.Fatalf("publication lost its cancellation diagnostic: %s", &output)
}
return output.Bytes(), err
case <-ctx.Done():
_ = operation.Kill()
_ = command.Process.Kill()
<-done
t.Fatal("canceled publication did not return")
}
return nil, nil
}

View File

@@ -0,0 +1,351 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"go.maximumdirect.net/releasekit"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
)
func TestPublicationPushGateHelper(t *testing.T) {
address := os.Getenv("RELEASEKIT_PUSH_GATE_ADDR")
if address == "" {
return
}
connection, err := net.DialTimeout("tcp", address, 10*time.Second)
if err != nil {
t.Fatalf("connect to pre-receive gate: %v", err)
}
defer connection.Close()
if _, err := fmt.Fprintln(connection, "ready"); err != nil {
t.Fatalf("announce pre-receive readiness: %v", err)
}
response, err := bufio.NewReader(connection).ReadString('\n')
if err != nil || strings.TrimSpace(response) != "reject" {
t.Fatalf("pre-receive gate response = %q, %v", response, err)
}
// The test process is the pre-receive hook's gate. Reject only after the
// parent has observed the pre-push record and explicitly released it.
t.Fatal("controlled rejection after pre-push record observation")
}
func TestLostPublicationOutputCanBeRecoveredFromExplicitRecords(t *testing.T) {
setIsolatedGitEnvironment(t)
repository := newCLIRepository(t)
store := filepath.Join(t.TempDir(), "publication records")
checkLog := filepath.Join(t.TempDir(), "candidate checks.log")
var hostedRequests atomic.Int64
hostedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hostedRequests.Add(1)
w.WriteHeader(http.StatusNotFound)
}))
defer hostedServer.Close()
settings := cli.Settings{
Product: "Example Product",
Remote: "origin",
Checks: []cli.CheckCommand{{
Args: []string{"/bin/sh", "-c", "printf 'candidate\\n' >> \"$RELEASEKIT_CANDIDATE_LOG\""},
Env: map[string]string{"RELEASEKIT_CANDIDATE_LOG": checkLog},
}},
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: hostedServer.URL, Owner: "example", Repository: "candidate"},
}
writeSettings := func() {
t.Helper()
contents, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(contents))
cliGitRun(t, repository, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "update publication profile")
}
writeSettings()
remote := filepath.Join(t.TempDir(), "original.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
nestedDir := "integrations/recovery"
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "go.mod")), "module example.com/cli-candidate/recovery\n\ngo 1.25.0\n")
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "candidate_test.go")), "package recovery\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "docs/releases/v1.2.3.md")), "# Example Product v1.2.3\n\nRecovery fixture.\n")
cliGitRun(t, repository, "add", nestedDir)
cliGitRun(t, repository, "commit", "-m", "add nested module")
cliGitRun(t, repository, "push", "origin", "main")
installPushGate(t, remote)
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
executable := filepath.Join(t.TempDir(), "releasekit recovery workflow")
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
build.Dir = repositoryRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build CLI: %v\n%s", err, output)
}
testBinary, err := os.Executable()
if err != nil {
t.Fatal(err)
}
rootTag := "v1.2.3"
rootRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, rootTag, "")
if rootRecord.Evidence.ModuleDir != repository || rootRecord.Evidence.Tag != rootTag {
t.Fatalf("root attempt evidence = %#v", rootRecord.Evidence)
}
settings.ModuleDir = nestedDir
writeSettings()
cliGitRun(t, repository, "push", "origin", "main")
nestedTag := nestedDir + "/v1.2.3"
nestedRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, nestedTag, nestedDir)
if nestedRecord.Evidence.ModuleDir != filepath.Join(repository, nestedDir) || nestedRecord.Evidence.Tag != nestedTag {
t.Fatalf("nested attempt evidence = %#v", nestedRecord.Evidence)
}
if rootRecord.RecordPath == nestedRecord.RecordPath {
t.Fatalf("publication attempts reused record path %q", rootRecord.RecordPath)
}
// Replace the rejected root tag on the original endpoint with a different
// annotated object, then make the current checkout point somewhere else.
hook := filepath.Join(remote, "hooks", "pre-receive")
if err := os.Remove(hook); err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "tag", "-f", "-a", rootTag, "-m", "remote replacement after failed attempt", "HEAD")
cliGitRun(t, repository, "push", "origin", "refs/tags/"+rootTag)
decoyRemote := filepath.Join(t.TempDir(), "replacement-origin.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", decoyRemote)
cliGitRun(t, repository, "remote", "set-url", "origin", decoyRemote)
cliGitRun(t, repository, "switch", "-c", "recovery-inspection")
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "move recovery checkout")
cliGitRun(t, repository, "tag", "-d", rootTag, nestedTag)
writeRepositoryFile(t, repository, "untracked recovery marker", "preserve this file\n")
before := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
listing := runCLIJSON(t, executable, t.TempDir(), "publication-attempts", "--repo", repository, "--record-dir", store, "--json")
var listed struct {
Attempts []struct {
RecordPath string `json:"record_path"`
Evidence struct {
Tag string `json:"tag"`
ModuleDir string `json:"module_dir"`
} `json:"evidence"`
} `json:"attempts"`
}
if err := json.Unmarshal(listing, &listed); err != nil {
t.Fatalf("decode publication-attempts output: %v\n%s", err, listing)
}
selectedRoot := ""
selectedNested := ""
for _, attempt := range listed.Attempts {
switch attempt.Evidence.Tag {
case rootTag:
selectedRoot = attempt.RecordPath
case nestedTag:
selectedNested = attempt.RecordPath
}
}
if selectedRoot != rootRecord.RecordPath || selectedNested != nestedRecord.RecordPath {
t.Fatalf("discovery selected root=%q nested=%q; want %q and %q; listing=%s", selectedRoot, selectedNested, rootRecord.RecordPath, nestedRecord.RecordPath, listing)
}
rootResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedRoot, "--json")
if rootResult.status != 1 || rootResult.document["state"] != "conflict" {
t.Fatalf("explicit root reconciliation status=%d document=%s; want conflict on the recorded original endpoint", rootResult.status, rootResult.output)
}
nestedResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedNested, "--json")
if nestedResult.status != 1 || nestedResult.document["state"] != "not_published" {
t.Fatalf("explicit nested reconciliation status=%d document=%s; want not_published", nestedResult.status, nestedResult.output)
}
if got := hostedRequests.Load(); got != 0 {
t.Fatalf("later recovery made %d hosted delivery requests", got)
}
after := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
if before != after {
t.Fatalf("reconciliation changed local or saved state:\nbefore: %#v\nafter: %#v", before, after)
}
checkBytes, err := os.ReadFile(checkLog)
if err != nil {
t.Fatal(err)
}
if got := strings.Count(string(checkBytes), "candidate\n"); got != 2 {
t.Fatalf("candidate checks ran %d times; want once per publication and none during recovery: %q", got, checkBytes)
}
}
type listedAttempt struct {
RecordPath string
Evidence releasekit.PublicationEvidence
}
func rejectPublicationAfterRecording(t *testing.T, executable, testBinary, repository, store, tag, expectedModule string) listedAttempt {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
commandArgs := []string{"publish", "--repo", repository, "--record-dir", store}
if expectedModule != "" {
commandArgs = append(commandArgs, "--expected-module", expectedModule)
}
commandArgs = append(commandArgs, "v1.2.3")
command := exec.Command(executable, commandArgs...)
command.Dir = repositoryRoot(t)
command.Env = append(os.Environ(), "RELEASEKIT_PUSH_GATE_ADDR="+listener.Addr().String(), "RELEASEKIT_PUSH_GATE_TEST_BINARY="+testBinary)
command.Stdout = io.Discard
command.Stderr = io.Discard
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer func() {
if command.Process != nil {
_ = command.Process.Kill()
_ = command.Wait()
}
}()
if tcpListener, ok := listener.(*net.TCPListener); ok {
if err := tcpListener.SetDeadline(time.Now().Add(90 * time.Second)); err != nil {
t.Fatal(err)
}
}
connection, err := listener.Accept()
if err != nil {
t.Fatalf("wait for pre-receive gate for %s: %v", tag, err)
}
defer connection.Close()
if tcpConnection, ok := connection.(*net.TCPConn); ok {
if err := tcpConnection.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
t.Fatal(err)
}
}
line, err := bufio.NewReader(connection).ReadString('\n')
if err != nil || strings.TrimSpace(line) != "ready" {
t.Fatalf("pre-receive gate readiness for %s = %q, %v", tag, line, err)
}
listed, err := releasekit.ListPublicationAttempts(context.Background(), releasekit.PublicationAttemptsRequest{RepositoryDir: repository, RecordDir: store})
if err != nil {
t.Fatalf("list attempts while tag push is held: %v", err)
}
var selected *releasekit.PublicationAttempt
for i := range listed.Attempts {
if listed.Attempts[i].Evidence.Tag == tag {
selected = &listed.Attempts[i]
break
}
}
if selected == nil {
t.Fatalf("pre-push record for %s was not visible before the hook continued: %#v", tag, listed.Attempts)
}
if selected.Evidence.CandidateCommit == "" || selected.Evidence.TagObject == "" || selected.Evidence.Endpoint == "" {
t.Fatalf("pre-push evidence for %s is incomplete: %#v", tag, selected.Evidence)
}
if _, err := fmt.Fprintln(connection, "reject"); err != nil {
t.Fatalf("release pre-receive gate for %s: %v", tag, err)
}
waitErr := command.Wait()
command.Process = nil
if waitErr == nil {
t.Fatalf("publish %s succeeded despite the controlled rejection", tag)
} else {
var exitError *exec.ExitError
if !errors.As(waitErr, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("publish %s exit = %v; want publication failure status 1", tag, waitErr)
}
}
return listedAttempt{RecordPath: selected.RecordPath, Evidence: selected.Evidence}
}
func installPushGate(t *testing.T, remote string) {
t.Helper()
hook := filepath.Join(remote, "hooks", "pre-receive")
script := "#!/bin/sh\nwhile read old new ref; do\n case \"$ref\" in\n refs/tags/*) \"$RELEASEKIT_PUSH_GATE_TEST_BINARY\" -test.run='^TestPublicationPushGateHelper$'; exit $? ;;\n esac\ndone\nexit 0\n"
if err := os.WriteFile(hook, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
}
func setIsolatedGitEnvironment(t *testing.T) {
t.Helper()
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_TERMINAL_PROMPT", "0")
}
func runCLIJSON(t *testing.T, executable, directory string, args ...string) []byte {
t.Helper()
result := runCLIStatusJSON(t, executable, directory, args...)
if result.status != 0 {
t.Fatalf("CLI %v exited with status %d: %s", args, result.status, result.output)
}
return result.output
}
type cliJSONResult struct {
status int
output []byte
document map[string]any
}
func runCLIStatusJSON(t *testing.T, executable, directory string, args ...string) cliJSONResult {
t.Helper()
command := exec.Command(executable, args...)
command.Dir = directory
output, err := command.CombinedOutput()
status := 0
if err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
t.Fatalf("run CLI %v: %v\n%s", args, err, output)
}
status = exitError.ExitCode()
}
var document map[string]any
if err := json.Unmarshal(output, &document); err != nil {
t.Fatalf("decode CLI JSON for %v: %v\n%s", args, err, output)
}
return cliJSONResult{status: status, output: output, document: document}
}
type recoverySnapshot struct {
Branch string
Head string
Worktree string
LocalRefs string
RemoteRefs string
Records string
}
func snapshotRecoveryRepository(t *testing.T, repository, remote string, recordPaths ...string) recoverySnapshot {
t.Helper()
branch := cliGitOutput(t, repository, "branch", "--show-current")
head := cliGitOutput(t, repository, "rev-parse", "HEAD")
worktree := cliGitOutput(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
localRefs := cliGitOutput(t, repository, "show-ref")
remoteRefs := cliGitOutput(t, remote, "show-ref")
var records strings.Builder
for _, path := range recordPaths {
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved record %s: %v", path, err)
}
fmt.Fprintf(&records, "%s\x00%s\n", path, contents)
}
return recoverySnapshot{Branch: branch, Head: head, Worktree: worktree, LocalRefs: localRefs, RemoteRefs: remoteRefs, Records: records.String()}
}

259
cmd/releasekit/recovery.go Normal file
View File

@@ -0,0 +1,259 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"go.maximumdirect.net/releasekit"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/git"
)
type recoveryDiagnostic struct {
Kind string `json:"kind"`
Message string `json:"message"`
RecordPath string `json:"record_path,omitempty"`
MissingFields []string `json:"missing_fields,omitempty"`
}
type safeEvidence struct {
RepositoryDir string `json:"repository_dir,omitempty"`
ModuleDir string `json:"module_dir,omitempty"`
ModulePath string `json:"module_path,omitempty"`
Version string `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
CandidateCommit string `json:"candidate_commit,omitempty"`
TagObject string `json:"tag_object,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Remote string `json:"remote,omitempty"`
RecordedAt string `json:"recorded_at,omitempty"`
}
type attemptDocument struct {
RecordPath string `json:"record_path"`
Evidence safeEvidence `json:"evidence"`
}
type reconciliationDocument struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
RecordPath string `json:"record_path"`
Evidence *safeEvidence `json:"evidence,omitempty"`
State string `json:"state,omitempty"`
ObservedTagObject string `json:"observed_tag_object,omitempty"`
ObservedCommit string `json:"observed_commit,omitempty"`
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
}
type attemptsDocument struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
RecordDir string `json:"record_dir"`
Attempts []attemptDocument `json:"attempts"`
Diagnostics []*recoveryDiagnostic `json:"diagnostics"`
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
}
func safe(e releasekit.PublicationEvidence) safeEvidence {
return safeEvidence{
RepositoryDir: git.DisplayDiagnostic(e.RepositoryDir),
ModuleDir: git.DisplayDiagnostic(e.ModuleDir),
ModulePath: git.DisplayDiagnostic(e.ModulePath),
Version: git.DisplayDiagnostic(e.Version),
Tag: git.DisplayDiagnostic(e.Tag),
CandidateCommit: git.DisplayDiagnostic(e.CandidateCommit),
TagObject: git.DisplayDiagnostic(e.TagObject),
Endpoint: git.DisplayDiagnostic(e.Endpoint),
Remote: git.DisplayDiagnostic(e.Remote),
RecordedAt: git.DisplayDiagnostic(e.RecordedAt),
}
}
func diagnostic(err error) *recoveryDiagnostic {
if err == nil {
return nil
}
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) {
missing := make([]string, len(typed.MissingFields))
for i, field := range typed.MissingFields {
missing[i] = git.DisplayDiagnostic(field)
}
return &recoveryDiagnostic{Kind: git.DisplayDiagnostic(typed.Kind), Message: git.DisplayError(err).Error(), RecordPath: git.DisplayDiagnostic(typed.RecordPath), MissingFields: missing}
}
return &recoveryDiagnostic{Kind: "invocation", Message: git.DisplayError(err).Error()}
}
func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
if invocation.Command == cli.ReconcileAction {
return runReconcile(ctx, invocation, stdout, stderr)
}
listed, err := releasekit.ListPublicationAttempts(ctx, releasekit.PublicationAttemptsRequest{RepositoryDir: invocation.Repository, RecordDir: invocation.RecordDir})
return reportAttempts(listed, err, invocation.JSON, stdout, stderr)
}
func reportAttempts(listed releasekit.PublicationAttemptsResult, err error, jsonOutput bool, stdout, stderr io.Writer) int {
if jsonOutput {
attempts := make([]attemptDocument, 0, len(listed.Attempts))
for _, item := range listed.Attempts {
attempts = append(attempts, attemptDocument{RecordPath: git.DisplayDiagnostic(item.RecordPath), Evidence: safe(item.Evidence)})
}
diagnostics := make([]*recoveryDiagnostic, 0, len(listed.Diagnostics))
for _, item := range listed.Diagnostics {
diagnostics = append(diagnostics, diagnostic(item))
}
if writeErr := writeJSON(stdout, attemptsDocument{
SchemaVersion: 1,
Command: "publication-attempts",
RecordDir: git.DisplayDiagnostic(listed.RecordDir),
Attempts: attempts,
Diagnostics: diagnostics,
Diagnostic: diagnostic(err),
}); writeErr != nil {
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
return 1
}
} else {
fmt.Fprintf(stdout, "publication records in %s\n", git.DisplayDiagnostic(listed.RecordDir))
for _, item := range listed.Attempts {
fmt.Fprintf(stdout, "%s module=%s tag=%s commit=%s recorded-at=%s\n", git.DisplayDiagnostic(item.RecordPath), git.DisplayDiagnostic(item.Evidence.ModulePath), git.DisplayDiagnostic(item.Evidence.Tag), git.DisplayDiagnostic(item.Evidence.CandidateCommit), git.DisplayDiagnostic(item.Evidence.RecordedAt))
}
if err != nil || len(listed.Diagnostics) > 0 {
fmt.Fprintln(stderr, "discovery is incomplete")
}
for _, item := range listed.Diagnostics {
fmt.Fprintf(stderr, "record %s: %s\n", git.DisplayDiagnostic(item.RecordPath), humanDiagnostic(item))
}
if err != nil {
fmt.Fprintf(stderr, "discovery failed: %v\n", git.DisplayError(err))
}
}
if err != nil || len(listed.Diagnostics) > 0 {
return 1
}
return 0
}
func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
evidence, loadErr := releasekit.LoadPublicationEvidence(invocation.RecordPath)
var result releasekit.PublicationReconciliationResult
err := loadErr
if err == nil {
result, err = releasekit.ReconcilePublication(ctx, evidence)
}
status := reconciliationStatus(result.State, err)
if invocation.JSON {
dto := reconciliationDocument{SchemaVersion: 1, Command: "reconcile", RecordPath: git.DisplayDiagnostic(invocation.RecordPath)}
if loadErr == nil {
safeEvidence := safe(evidence)
dto.Evidence = &safeEvidence
dto.State = string(result.State)
dto.ObservedTagObject = result.ObservedTagObject
dto.ObservedCommit = result.ObservedCommit
}
dto.Diagnostic = diagnostic(err)
if writeErr := writeJSON(stdout, dto); writeErr != nil {
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
return 1
}
} else if err == nil {
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
if result.State == releasekit.PublicationConflict {
fmt.Fprintf(stdout, "expected tag-object=%s commit=%s; observed tag-object=%s commit=%s\n", git.DisplayDiagnostic(evidence.TagObject), git.DisplayDiagnostic(evidence.CandidateCommit), result.ObservedTagObject, result.ObservedCommit)
} else if result.ObservedTagObject != "" || result.ObservedCommit != "" {
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
}
if guidance := reconciliationGuidance(result.State); guidance != "" {
fmt.Fprintf(stdout, "%s\n", guidance)
}
}
if err != nil && !invocation.JSON {
if loadErr == nil {
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
if result.ObservedTagObject != "" || result.ObservedCommit != "" {
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
}
}
fmt.Fprintf(stderr, "reconcile %s: %s\n", git.DisplayDiagnostic(invocation.RecordPath), humanDiagnostic(err))
if guidance := recoveryGuidance(err); guidance != "" {
fmt.Fprintf(stderr, "%s\n", guidance)
}
}
return status
}
func reconciliationStatus(state releasekit.PublicationState, err error) int {
if err == nil {
switch state {
case releasekit.PublicationPublished:
return 0
case releasekit.PublicationNotPublished, releasekit.PublicationConflict:
return 1
case releasekit.PublicationUnknown:
return 3
default:
return 2
}
}
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) {
switch typed.Kind {
case "record_read", "record_format":
return 2
default:
return 3
}
}
return 2
}
func recoveryGuidance(err error) string {
var typed *releasekit.PublicationRecoveryError
if !errors.As(err, &typed) {
return ""
}
switch typed.Kind {
case "missing_evidence":
return "Select a record with the missing identity fields, or provide complete typed evidence through the Go API."
case "invalid_evidence":
return "Use a record with a valid full tag and Git object ID."
case "endpoint_changed":
return "Review Git URL rewrite settings and restore the captured endpoint mapping before retrying."
case "transport_configuration":
return "Review Git configuration and the supported transport settings, then retry observation."
case "canceled":
return "Retry observation with a context that allows it to complete."
case "remote_observation", "inconsistent_observation":
return "Review the Git diagnostic and the remote tag refs before taking any corrective action."
default:
return ""
}
}
func reconciliationGuidance(state releasekit.PublicationState) string {
switch state {
case releasekit.PublicationNotPublished:
return "This reports current absence only; review publication history and version policy before retrying."
case releasekit.PublicationConflict:
return "Do not replace a conflicting remote tag; verify the observed IDs and follow the repository's immutable-tag procedure."
default:
return ""
}
}
func humanDiagnostic(err error) string {
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) && len(typed.MissingFields) > 0 {
return git.DisplayDiagnostic(fmt.Sprintf("%s (missing fields: %s): %s", typed.Kind, strings.Join(typed.MissingFields, ", "), err.Error()))
}
return git.DisplayError(err).Error()
}
func writeJSON(writer io.Writer, value any) error {
encoder := json.NewEncoder(writer)
return encoder.Encode(value)
}

View File

@@ -0,0 +1,487 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
releasekit "go.maximumdirect.net/releasekit"
"go.maximumdirect.net/releasekit/internal/publish"
)
func TestRecoveryEndpointSetupFailureDiagnostics(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture injects Git failures through a POSIX launcher")
}
for _, test := range []struct {
name, remote, kind, guidance string
exitCode int
}{
{"unnamed configuration", "", "transport_configuration", "Git configuration", 128},
{"named configuration", "origin", "transport_configuration", "Git configuration", 128},
{"rewrite", "", "endpoint_changed", "URL rewrite", 0},
{"tooling", "", "remote_observation", "Git diagnostic", 71},
} {
t.Run(test.name, func(t *testing.T) {
repo := newCLIRepository(t)
evidence := publish.Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3", Remote: test.remote}
path, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
if err != nil {
t.Fatal(err)
}
if test.kind == "transport_configuration" {
file, err := os.OpenFile(filepath.Join(repo, ".git", "config"), os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
if _, err := file.WriteString("\n[broken\n"); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
} else if test.kind == "endpoint_changed" {
cliGitRun(t, repo, "config", "url."+t.TempDir()+".insteadOf", repo)
}
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
marker := filepath.Join(t.TempDir(), "queried")
bin := t.TempDir()
failValidation := ""
if test.name == "tooling" {
failValidation = "[ \"$arg\" = check-ref-format ] && exit 71\n"
}
script := "#!/bin/sh\nquery=0\nget_url=0\nfor arg in \"$@\"; do\n" + failValidation + "[ \"$arg\" = ls-remote ] && query=1\n[ \"$arg\" = --get-url ] && get_url=1\ndone\nif [ \"$query\" = 1 ] && [ \"$get_url\" = 0 ]; then : > " + shellQuote(marker) + "; exit 72; fi\nexec " + shellQuote(realGit) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
loaded, err := releasekit.LoadPublicationEvidence(path)
if err != nil {
t.Fatal(err)
}
result, err := releasekit.ReconcilePublication(t.Context(), loaded)
var typed *releasekit.PublicationRecoveryError
if !errors.As(err, &typed) || typed.Kind != test.kind || result.State != releasekit.PublicationUnknown {
t.Fatalf("setup classification = %#v, %v", result, err)
}
if test.exitCode != 0 {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != test.exitCode {
t.Fatalf("lost Git cause: %v", err)
}
}
for _, structured := range []bool{false, true} {
args := []string{"reconcile", "--record", path}
if structured {
args = append(args, "--json")
}
var stdout, stderr bytes.Buffer
if status := run(t.Context(), args, &stdout, &stderr); status != 3 {
t.Fatalf("setup status=%d: %s%s", status, &stdout, &stderr)
}
if structured {
var document reconciliationDocument
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
t.Fatal(err)
}
if document.State != "unknown" || document.Diagnostic == nil || document.Diagnostic.Kind != test.kind {
t.Fatalf("setup JSON: %s", &stdout)
}
} else if !strings.Contains(stderr.String(), test.kind) || !strings.Contains(stderr.String(), test.guidance) {
t.Fatalf("setup guidance: %s", &stderr)
}
}
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("setup failure reached remote query: %v", err)
}
})
}
}
type failingRecoveryWriter struct{ err error }
func (writer failingRecoveryWriter) Write([]byte) (int, error) { return 0, writer.err }
func TestRecoveryCommandsReportJSONWriteFailure(t *testing.T) {
repo := newCLIRepository(t)
cliGitRun(t, repo, "tag", "-a", "v1.2.3", "-m", "Release")
store := t.TempDir()
path, err := publish.SaveEvidence(t.Context(), repo, store, publish.Evidence{
RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3",
CandidateCommit: cliGitOutput(t, repo, "rev-parse", "HEAD"),
TagObject: cliGitOutput(t, repo, "rev-parse", "v1.2.3"),
})
if err != nil {
t.Fatal(err)
}
failure := errors.New("output unavailable: https://user:audit-secret@example.invalid/output")
for _, args := range [][]string{{"reconcile", "--record", path, "--json"}, {"publication-attempts", "--record-dir", store, "--json"}} {
var normal, stderr bytes.Buffer
if status := run(t.Context(), args, &normal, &stderr); status != 0 {
t.Fatalf("normal output status=%d: %s", status, &stderr)
}
stderr.Reset()
if status := run(t.Context(), args, failingRecoveryWriter{failure}, &stderr); status != 1 {
t.Fatalf("failed output status=%d: %s", status, &stderr)
}
if !strings.Contains(stderr.String(), "output unavailable") || strings.Contains(stderr.String(), "audit-secret") {
t.Fatalf("output diagnostic: %s", &stderr)
}
if args[0] == "reconcile" && !strings.Contains(normal.String(), `"state":"published"`) {
t.Fatalf("observation changed: %s", &normal)
}
}
}
func TestDiscoveryReportsPartialAttemptsAndEnumerationFailure(t *testing.T) {
listed := releasekit.PublicationAttemptsResult{
RecordDir: "records",
Attempts: []releasekit.PublicationAttempt{{RecordPath: "good.json", Evidence: releasekit.PublicationEvidence{Tag: "v1.2.3"}}},
Diagnostics: []*releasekit.PublicationRecoveryError{{Kind: "record_format", RecordPath: "broken.json"}},
}
failure := &releasekit.PublicationRecoveryError{Kind: "storage_enumeration"}
for _, jsonOutput := range []bool{false, true} {
var stdout, stderr bytes.Buffer
if status := reportAttempts(listed, failure, jsonOutput, &stdout, &stderr); status != 1 {
t.Fatalf("partial discovery status=%d", status)
}
if jsonOutput {
var document attemptsDocument
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
t.Fatal(err)
}
if len(document.Attempts) != 1 || document.Attempts[0].RecordPath != "good.json" || len(document.Diagnostics) != 1 || document.Diagnostics[0].RecordPath != "broken.json" || document.Diagnostic == nil || document.Diagnostic.Kind != "storage_enumeration" {
t.Fatalf("partial JSON lost data: %s", &stdout)
}
} else if !strings.Contains(stdout.String(), "good.json") || !strings.Contains(stderr.String(), "broken.json") || !strings.Contains(stderr.String(), "storage_enumeration") {
t.Fatalf("partial human output lost data: %s%s", &stdout, &stderr)
}
}
}
func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) {
secret := "audit-secret"
raw := "https://audit-user:" + secret + "@example.invalid/attempt.json"
for _, jsonOutput := range []bool{false, true} {
args := []string{"reconcile", "--record", raw}
if jsonOutput {
args = append(args, "--json")
}
var out, diagnostics bytes.Buffer
if status := run(t.Context(), args, &out, &diagnostics); status != 2 {
t.Fatalf("load status=%d", status)
}
if strings.Contains(out.String()+diagnostics.String(), secret) {
t.Fatalf("credential leaked: %s%s", &out, &diagnostics)
}
}
repo := newCLIRepository(t)
store := t.TempDir()
evidence := publish.Evidence{RepositoryDir: raw, ModuleDir: raw, ModulePath: raw, Version: raw, Tag: raw, CandidateCommit: raw, TagObject: raw, Remote: raw, RecordedAt: raw}
path, err := publish.SaveEvidence(t.Context(), repo, store, evidence)
if err != nil {
t.Fatal(err)
}
for _, command := range [][]string{{"reconcile", "--record", path}, {"publication-attempts", "--record-dir", store}} {
for _, jsonOutput := range []bool{false, true} {
args := append([]string{}, command...)
if jsonOutput {
args = append(args, "--json")
}
var out, diagnostics bytes.Buffer
run(t.Context(), args, &out, &diagnostics)
if strings.Contains(out.String()+diagnostics.String(), secret) {
t.Fatalf("metadata leaked: %s%s", &out, &diagnostics)
}
}
}
loaded, err := publish.LoadEvidence(path)
if err != nil || loaded != evidence {
t.Fatalf("private metadata changed: %#v, %v", loaded, err)
}
var out bytes.Buffer
reportPublicationRecord(t.Context(), releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out)
if strings.Contains(out.String(), secret) {
t.Fatalf("handoff leaked credentials: %s", &out)
}
}
func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing.T) {
repository := newCLIRepository(t)
remote := filepath.Join(t.TempDir(), "bare remote.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "tag", "-a", "v1.2.3", "-m", "first tag object")
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
store := filepath.Join(t.TempDir(), "records with ' punctuation")
evidence := publish.Evidence{
RepositoryDir: repository,
ModuleDir: repository,
ModulePath: "example.com/cli-candidate",
Version: "v1.2.3",
Tag: "v1.2.3",
CandidateCommit: commit,
TagObject: tagObject,
Endpoint: remote,
Remote: "origin",
RecordedAt: "2026-11-02T00:00:00Z",
}
record, err := publish.SaveEvidence(t.Context(), repository, store, evidence)
if err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "switch", "-c", "changed-head")
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "change current HEAD")
cliGitRun(t, repository, "tag", "-d", "v1.2.3")
if err := os.WriteFile(filepath.Join(repository, "uncommitted recovery check.txt"), []byte("dirty"), 0o600); err != nil {
t.Fatal(err)
}
otherDirectory := t.TempDir()
relativeRecord, err := filepath.Rel(otherDirectory, record)
if err != nil {
t.Fatal(err)
}
previousDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(otherDirectory); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chdir(previousDirectory); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"reconcile", "--record", relativeRecord, "--json"}, &stdout, &stderr)
if status != 0 || stderr.Len() != 0 {
t.Fatalf("published status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
var published map[string]any
if err := json.Unmarshal(stdout.Bytes(), &published); err != nil {
t.Fatalf("decode published JSON: %v; output = %q", err, stdout.String())
}
if published["state"] != string("published") || published["record_path"] != relativeRecord {
t.Fatalf("published JSON = %#v", published)
}
if _, ok := published["diagnostic"]; ok {
t.Fatalf("successful reconciliation includes a diagnostic: %#v", published)
}
evidenceJSON, ok := published["evidence"].(map[string]any)
if !ok || evidenceJSON["candidate_commit"] != commit || evidenceJSON["tag_object"] != tagObject {
t.Fatalf("safe evidence = %#v", published["evidence"])
}
if evidenceJSON["module_path"] != evidence.ModulePath || evidenceJSON["candidateCommit"] != nil {
t.Fatalf("evidence does not use the versioned snake-case shape: %#v", evidenceJSON)
}
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
cliGitRun(t, repository, "tag", "-f", "-a", "v1.2.3", "-m", "replacement tag object")
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
var conflictOut, conflictErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &conflictOut, &conflictErr)
if status != 1 || conflictErr.Len() != 0 {
t.Fatalf("conflict status = %d, stdout = %q, stderr = %q", status, conflictOut.String(), conflictErr.String())
}
var conflict map[string]any
if err := json.Unmarshal(conflictOut.Bytes(), &conflict); err != nil {
t.Fatal(err)
}
if conflict["state"] != string("conflict") || conflict["observed_tag_object"] == tagObject {
t.Fatalf("conflict JSON = %#v", conflict)
}
var humanOut, humanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record}, &humanOut, &humanErr)
if status != 1 || !strings.Contains(humanOut.String(), "expected tag-object="+tagObject) || !strings.Contains(humanOut.String(), "observed tag-object=") || !strings.Contains(humanOut.String(), "Do not replace a conflicting remote tag") {
t.Fatalf("human conflict status = %d, stdout = %q, stderr = %q", status, humanOut.String(), humanErr.String())
}
partialEvidence := evidence
partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
partialEvidence.CandidateCommit = ""
partialRecord, err := publish.SaveEvidence(t.Context(), repository, store, partialEvidence)
if err != nil {
t.Fatal(err)
}
var unknownOut, unknownErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", partialRecord, "--json"}, &unknownOut, &unknownErr)
if status != 3 || unknownErr.Len() != 0 {
t.Fatalf("unknown status = %d, stdout = %q, stderr = %q", status, unknownOut.String(), unknownErr.String())
}
var unknown map[string]any
if err := json.Unmarshal(unknownOut.Bytes(), &unknown); err != nil {
t.Fatal(err)
}
unknownDiagnostic, ok := unknown["diagnostic"].(map[string]any)
if unknown["state"] != "unknown" || !ok || unknownDiagnostic["kind"] != "missing_evidence" {
t.Fatalf("unknown JSON = %#v", unknown)
}
var unknownHumanOut, unknownHumanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", partialRecord}, &unknownHumanOut, &unknownHumanErr)
if status != 3 || !strings.Contains(unknownHumanOut.String(), "state=unknown") || !strings.Contains(unknownHumanErr.String(), "candidate_commit") {
t.Fatalf("human unknown status = %d, stdout = %q, stderr = %q", status, unknownHumanOut.String(), unknownHumanErr.String())
}
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
var absentOut, absentErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &absentOut, &absentErr)
if status != 1 || absentErr.Len() != 0 {
t.Fatalf("absent status = %d, stdout = %q, stderr = %q", status, absentOut.String(), absentErr.String())
}
var absent map[string]any
if err := json.Unmarshal(absentOut.Bytes(), &absent); err != nil {
t.Fatal(err)
}
if absent["state"] != "not_published" {
t.Fatalf("absence JSON = %#v", absent)
}
if _, ok := absent["diagnostic"]; ok {
t.Fatalf("known absence includes an unresolved diagnostic: %#v", absent)
}
var absentHumanOut, absentHumanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record}, &absentHumanOut, &absentHumanErr)
if status != 1 || !strings.Contains(absentHumanOut.String(), "state=not_published") || !strings.Contains(absentHumanOut.String(), "current absence only") {
t.Fatalf("human absence status = %d, stdout = %q, stderr = %q", status, absentHumanOut.String(), absentHumanErr.String())
}
brokenRecord := filepath.Join(store, "broken record's.json")
if err := os.WriteFile(brokenRecord, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
var brokenOut, brokenErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", brokenRecord, "--json"}, &brokenOut, &brokenErr)
if status != 2 || brokenErr.Len() != 0 {
t.Fatalf("record-format status = %d, stdout = %q, stderr = %q", status, brokenOut.String(), brokenErr.String())
}
var broken map[string]any
if err := json.Unmarshal(brokenOut.Bytes(), &broken); err != nil {
t.Fatal(err)
}
brokenDiagnostic, ok := broken["diagnostic"].(map[string]any)
if !ok || brokenDiagnostic["kind"] != "record_format" || broken["record_path"] != brokenRecord {
t.Fatalf("record-format JSON = %#v", broken)
}
for _, field := range []string{"evidence", "state", "observed_tag_object", "observed_commit"} {
if _, ok := broken[field]; ok {
t.Errorf("record-format JSON includes %q: %#v", field, broken)
}
}
}
func TestPublicationAttemptsCommandRedactsCredentialsAndReportsPartialDiscovery(t *testing.T) {
repository := newCLIRepository(t)
store := filepath.Join(t.TempDir(), "external records")
evidence := publish.Evidence{
RepositoryDir: repository,
ModulePath: "example.com/cli-candidate",
Version: "v1.2.3",
Tag: "v1.2.3",
Endpoint: "https://cli-user:cli-secret@example.test/repository.git",
Remote: "origin",
RecordedAt: "2026-11-02T00:00:00Z",
}
if _, err := publish.SaveEvidence(t.Context(), repository, store, evidence); err != nil {
t.Fatal(err)
}
brokenPath := filepath.Join(store, "damaged.json")
if err := os.WriteFile(brokenPath, []byte(`{"format_version":99,"evidence":{}}`), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"publication-attempts", "--record-dir", store, "--json"}, &stdout, &stderr)
if status != 1 || stderr.Len() != 0 {
t.Fatalf("partial discovery status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
output := stdout.String()
if strings.Contains(output, "cli-user") || strings.Contains(output, "cli-secret") || !strings.Contains(output, "https://redacted@example.test/repository.git") {
t.Fatalf("structured discovery exposed or failed to redact endpoint credentials: %q", output)
}
var listed map[string]any
if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil {
t.Fatal(err)
}
attempts, ok := listed["attempts"].([]any)
if !ok || len(attempts) != 1 {
t.Fatalf("attempts = %#v", listed["attempts"])
}
diagnostics, ok := listed["diagnostics"].([]any)
if !ok || len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v", listed["diagnostics"])
}
if listed["schema_version"] != float64(1) || listed["command"] != "publication-attempts" || listed["diagnostic"] != nil {
t.Fatalf("listing document = %#v", listed)
}
entry := attempts[0].(map[string]any)
if entry["record_path"] == "" {
t.Fatalf("attempt is missing record_path: %#v", entry)
}
entryEvidence := entry["evidence"].(map[string]any)
if entryEvidence["module_path"] != evidence.ModulePath || entryEvidence["recorded_at"] != evidence.RecordedAt {
t.Fatalf("attempt evidence = %#v", entryEvidence)
}
diagnostic := diagnostics[0].(map[string]any)
if diagnostic["kind"] != "record_format" || diagnostic["record_path"] != brokenPath {
t.Fatalf("record diagnostic = %#v", diagnostic)
}
emptyStore := filepath.Join(t.TempDir(), "empty store")
if err := os.Mkdir(emptyStore, 0o700); err != nil {
t.Fatal(err)
}
var emptyOut, emptyErr bytes.Buffer
status = run(context.Background(), []string{"publication-attempts", "--record-dir", emptyStore, "--json"}, &emptyOut, &emptyErr)
if status != 0 || emptyErr.Len() != 0 {
t.Fatalf("empty discovery status = %d, stdout = %q, stderr = %q", status, emptyOut.String(), emptyErr.String())
}
var empty map[string]any
if err := json.Unmarshal(emptyOut.Bytes(), &empty); err != nil {
t.Fatal(err)
}
if len(empty["attempts"].([]any)) != 0 || len(empty["diagnostics"].([]any)) != 0 || empty["diagnostic"] != nil {
t.Fatalf("empty listing document = %#v", empty)
}
var failedOut, failedErr bytes.Buffer
status = run(context.Background(), []string{"publication-attempts", "--record-dir", filepath.Join(emptyStore, "missing"), "--json"}, &failedOut, &failedErr)
if status != 1 || failedErr.Len() != 0 {
t.Fatalf("enumeration failure status = %d, stdout = %q, stderr = %q", status, failedOut.String(), failedErr.String())
}
var failed map[string]any
if err := json.Unmarshal(failedOut.Bytes(), &failed); err != nil {
t.Fatal(err)
}
failedDiagnostic, ok := failed["diagnostic"].(map[string]any)
if !ok || failedDiagnostic["kind"] != "storage_enumeration" {
t.Fatalf("enumeration failure JSON = %#v", failed)
}
}
func TestRecoveryArgumentErrorsUseExistingUsagePath(t *testing.T) {
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"reconcile", "--json"}, &stdout, &stderr)
if status != 2 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "reconcile requires --record FILE") {
t.Fatalf("argument failure status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
if strings.Contains(stdout.String(), "schema_version") || strings.Contains(stderr.String(), fmt.Sprint("\"command\"")) {
t.Fatalf("argument failure unexpectedly used structured output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}

View File

@@ -79,9 +79,10 @@ func TestReleaseCandidate(t *testing.T) {
"product": "ReleaseKit", "product": "ReleaseKit",
"checks": []map[string]any{{"args": []string{"sh", "-c", "if [ -n \"$RELEASEKIT_TEST_ARM_GIT_SHIM\" ]; then : > \"$RELEASEKIT_TEST_ARM_GIT_SHIM\"; fi"}}}, "checks": []map[string]any{{"args": []string{"sh", "-c", "if [ -n \"$RELEASEKIT_TEST_ARM_GIT_SHIM\" ]; then : > \"$RELEASEKIT_TEST_ARM_GIT_SHIM\"; fi"}}},
"delivery": map[string]any{ "delivery": map[string]any{
"base_url": "https://gitea.maximumdirect.net", "forge": "gitea",
"owner": "eric", "base_url": "https://forge.maximumdirect.net",
"repository": "ReleaseKit", "owner": "base-layer",
"repository": "releasekit",
"token_env": "RELEASEKIT_GITEA_TOKEN", "token_env": "RELEASEKIT_GITEA_TOKEN",
}, },
} }

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
) )
func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) { func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
@@ -24,7 +24,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
if profile.Build != nil && len(profile.Build.Targets) != 0 { if profile.Build != nil && len(profile.Build.Targets) != 0 {
t.Fatalf("ReleaseKit profile declares binary targets: %#v", profile.Build.Targets) t.Fatalf("ReleaseKit profile declares binary targets: %#v", profile.Build.Targets)
} }
if profile.Delivery == nil || profile.Delivery.BaseURL != "https://gitea.maximumdirect.net" || profile.Delivery.Owner != "eric" || profile.Delivery.Repository != "ReleaseKit" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" { if profile.Delivery == nil || profile.Delivery.BaseURL != "https://forge.maximumdirect.net" || profile.Delivery.Owner != "base-layer" || profile.Delivery.Repository != "releasekit" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" {
t.Fatalf("ReleaseKit delivery profile = %#v", profile.Delivery) t.Fatalf("ReleaseKit delivery profile = %#v", profile.Delivery)
} }
repository := filepath.Join(t.TempDir(), "tagged ReleaseKit") repository := filepath.Join(t.TempDir(), "tagged ReleaseKit")
@@ -37,9 +37,9 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
const version = "v1.2.3" const version = "v1.2.3"
note := "# ReleaseKit v1.2.3\n\nSource-only delivery fixture.\n\n" + note := "# ReleaseKit v1.2.3\n\nSource-only delivery fixture.\n\n" +
"[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/COPYRIGHT) |\n" + "[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v1.2.3/COPYRIGHT) |\n" +
"[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/LICENSE) |\n" + "[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v1.2.3/LICENSE) |\n" +
"[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/docs/oss/attribution.md)\n" "[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v1.2.3/docs/oss/attribution.md)\n"
notePath := filepath.Join(repository, "docs", "releases", version+".md") notePath := filepath.Join(repository, "docs", "releases", version+".md")
if err := os.MkdirAll(filepath.Dir(notePath), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(notePath), 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -55,7 +55,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
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{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
state.update(func(state *deliveryServerData) { 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")
@@ -78,7 +78,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
} }
env := map[string]string{ env := map[string]string{
"CI_WORKSPACE": repository, "CI_WORKSPACE": repository,
"CI_COMMIT_SHA": state.snapshot().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", "GOTOOLCHAIN": "local",

View File

@@ -0,0 +1,111 @@
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
)
func TestDeliveryTokenFileSelection(t *testing.T) {
for _, test := range []struct {
name, env, contents, file, want string
status int
}{
{name: "relative file", contents: "fixture-token\n", file: "token", want: "fixture-token"},
{name: "absolute file", contents: "fixture-token\n", file: "absolute", want: "fixture-token"},
{name: "environment overrides missing file", env: "env-token", file: "missing", want: "env-token"},
{name: "blank environment falls back", env: " \t", contents: "fixture-token\n", file: "token", want: "fixture-token"},
{name: "missing file", file: "missing", status: 2},
{name: "blank file", contents: " \n\t", file: "token", status: 2},
{name: "empty file", file: "token", status: 2},
{name: "unreadable as file", file: ".", status: 2},
{name: "no credential", status: 2},
} {
t.Run(test.name, func(t *testing.T) {
repository := t.TempDir()
if err := os.WriteFile(filepath.Join(repository, "token"), []byte(test.contents), 0o600); err != nil {
t.Fatal(err)
}
name := test.file
if name == "absolute" {
name = filepath.Join(repository, "token")
}
t.Setenv("RELEASEKIT_TEST_TOKEN", test.env)
var stderr bytes.Buffer
destination, status := deliveryDestination(repository, cli.Settings{Delivery: &cli.DeliverySettings{Forge: "gitea",
BaseURL: "https://gitea.example", Owner: "owner", Repository: "example",
TokenEnv: "RELEASEKIT_TEST_TOKEN", TokenFile: name,
}}, &stderr)
if status != test.status || destination.Token != test.want {
t.Fatalf("status=%d token matches=%t, want status=%d; diagnostics=%s", status, destination.Token == test.want, test.status, &stderr)
}
for _, secret := range []string{"fixture-token", "env-token"} {
if strings.Contains(stderr.String(), secret) {
t.Fatal("diagnostics exposed a token")
}
}
})
}
}
func TestCheckReadsForbiddenFilesSelectionFromProfile(t *testing.T) {
repository := newCLIRepository(t)
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Validation: &cli.ValidationSettings{ForbiddenFiles: []string{"token-file"}},
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: "https://gitea.example", Owner: "owner", Repository: "example", TokenFile: "missing-token-file"},
})
writeRepositoryFile(t, repository, "token-file", "fixture-secret\n")
cliGitRun(t, repository, "add", "token-file")
cliGitRun(t, repository, "commit", "-m", "capture a selected token path")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"check", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), "forbidden-files") || !strings.Contains(stderr.String(), "token-file") {
t.Fatalf("check status=%d output=%s diagnostics=%s", status, &stdout, &stderr)
}
if strings.Contains(stdout.String()+stderr.String(), "fixture-secret") {
t.Fatal("check exposed the token")
}
}
func TestDeliverAndCIDeliverReadTokenFileFromRepositoryRoot(t *testing.T) {
for _, command := range []string{"deliver", "ci-deliver"} {
t.Run(command, func(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example", TokenFile: ".releasekit/token"},
})
writeRepositoryFile(t, repository, ".gitignore", ".releasekit/\n")
cliGitRun(t, repository, "add", ".gitignore")
cliGitRun(t, repository, "commit", "-m", "ignore local delivery credentials")
commit := pushTaggedCLIRepository(t, repository)
state.update(func(data *deliveryServerData) { data.commit = commit })
writeRepositoryFile(t, repository, ".releasekit/token", "fixture-token\n")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "")
var stdout, stderr bytes.Buffer
args := []string{command, "--repo", filepath.Join(repository, ".releasekit")}
if command == "ci-deliver" {
args = append(args, "--commit", commit)
}
args = append(args, "v1.2.3")
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("delivery status=%d output=%s diagnostics=%s", status, &stdout, &stderr)
}
if state.snapshot().releaseCreates != 1 {
t.Fatal("token-file delivery did not create the release page")
}
if strings.Contains(stdout.String()+stderr.String(), "fixture-token") {
t.Fatal("delivery exposed the token")
}
})
}
}

View File

@@ -15,7 +15,7 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
) )
func TestWoodpeckerTagDeliveryTemplate(t *testing.T) { func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
@@ -39,7 +39,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{ writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product", Product: "Example Product",
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
state.update(func(state *deliveryServerData) { state.commit = commit }) state.update(func(state *deliveryServerData) { state.commit = commit })
@@ -70,7 +70,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
BinaryName: "helper", BinaryName: "helper",
Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}, Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)},
}, },
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
state.update(func(state *deliveryServerData) { state.commit = commit }) state.update(func(state *deliveryServerData) { state.commit = commit })
@@ -104,10 +104,14 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
selectReleaseKitModule(t, repository, root, false) selectReleaseKitModule(t, repository, root, false)
commit := pushTaggedCLIRepository(t, repository) 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) goDir, goLog := installControlledGoBootstrapShim(t)
env := templateEnvironment(repository, strings.Repeat("f", len(commit)), goDir, goLog) env := templateEnvironment(repository, unrelated, goDir, goLog)
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("wrong tag succeeded: %s", output) 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) assertNoCommands(t, goLog)
}) })
@@ -126,26 +130,49 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
}) })
t.Run("selected executable receives event arguments and its status", func(t *testing.T) { t.Run("selected executable receives event arguments and its status", func(t *testing.T) {
repository := newCLIRepository(t) for _, event := range []struct {
selectReleaseKitModule(t, repository, root, false) name, tag string
commit := pushTaggedCLIRepository(t, repository) tagObject bool
goDir, goLog := installControlledGoBootstrapShim(t) }{
toolArgs := filepath.Join(t.TempDir(), "tool-arguments") {"lightweight commit", "v1.2.3", false},
env := templateEnvironment(repository, strings.ToUpper(commit), goDir, goLog) {"annotated commit", "v1.2.4", false},
env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs {"annotated tag object", "v1.2.4", true},
env["RELEASEKIT_TOOL_EXIT"] = "37" } {
output, err := executeWoodpeckerBlock(command, repository, env) t.Run(event.name, func(t *testing.T) {
var exitError *exec.ExitError repository := newCLIRepository(t)
if !errors.As(err, &exitError) || exitError.ExitCode() != 37 { selectReleaseKitModule(t, repository, root, false)
t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output) 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)
})
} }
args := readWoodpeckerFixtureFile(t, toolArgs)
for _, expected := range []string{"<ci-deliver>", "<--repo>", "<" + repository + ">", "<--prepared>", "<--commit>", "<" + strings.ToUpper(commit) + ">", "<v1.2.3>"} {
if !strings.Contains(args, expected) {
t.Errorf("selected executable arguments %q omit %q", args, expected)
}
}
assertGoSelection(t, repository, goLog)
}) })
} }
@@ -178,7 +205,7 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
t.Run("older tag retry uses its committed module selection", func(t *testing.T) { t.Run("older tag retry uses its committed module selection", func(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
repository := newCLIRepository(t) repository := newCLIRepository(t)
const module = "gitea.maximumdirect.net/eric/ReleaseKit" const module = "go.maximumdirect.net/releasekit"
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n") 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, "add", "go.mod")
cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version") cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version")
@@ -209,10 +236,30 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
assertToolOnlyChecksums(t, sumLog) assertToolOnlyChecksums(t, sumLog)
}) })
t.Run("vendored tool dependency", func(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t)
repository := newCLIRepository(t)
const module = "go.maximumdirect.net/releasekit"
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n\ntool "+module+"/cmd/releasekit\n")
env := templateEnvironment(repository, "", goDir, goLog)
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
runWoodpeckerBlock(t, "go mod tidy\ngo mod vendor", repository, env)
cliGitRun(t, repository, "add", "go.mod", "go.sum", "vendor")
cliGitRun(t, repository, "commit", "-m", "vendor the selected release tool")
env["CI_COMMIT_SHA"] = pushTaggedCLIRepository(t, repository)
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum"))
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "tool-version=v0.2.197 helper=tool-only dependency") {
t.Fatalf("vendored tool was not selected: %q", output)
}
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
})
t.Run("versioned replacement selects its declared source", func(t *testing.T) { t.Run("versioned replacement selects its declared source", func(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
repository := newCLIRepository(t) repository := newCLIRepository(t)
const module = "gitea.maximumdirect.net/eric/ReleaseKit" const module = "go.maximumdirect.net/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" 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) writeRepositoryFile(t, repository, "go.mod", goMod)
cliGitRun(t, repository, "add", "go.mod") cliGitRun(t, repository, "add", "go.mod")
@@ -373,7 +420,7 @@ func installGoBootstrapShim(t *testing.T) (string, string) {
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG" printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
"$RELEASEKIT_REAL_GO" "$@" "$RELEASEKIT_REAL_GO" "$@"
status=$? status=$?
if [ "$status" -eq 0 ] && [ "$1" = "build" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then if [ "$status" -eq 0 ] && [ "$1" = "tool" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
modfile= modfile=
for argument in "$@"; do for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
@@ -406,20 +453,12 @@ case "$1" in
list) list)
printf '%s v0.2.0\n' "$RELEASEKIT_MODULE" printf '%s v0.2.0\n' "$RELEASEKIT_MODULE"
;; ;;
build) mod)
output= ;;
previous= tool)
for argument in "$@"; do shift 3
if [ "$previous" = "-o" ]; then output=$argument; break; fi printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
previous=$argument exit "${RELEASEKIT_TOOL_EXIT:-0}"
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 echo "unexpected Go command: $1" >&2
@@ -453,7 +492,7 @@ func templateEnvironment(repository, commit, shimDir, goLog string) map[string]s
"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_MODULE": "gitea.maximumdirect.net/eric/ReleaseKit", "RELEASEKIT_MODULE": "go.maximumdirect.net/releasekit",
"RELEASEKIT_REAL_GO": realGo, "RELEASEKIT_REAL_GO": realGo,
"RELEASEKIT_GO_LOG": goLog, "RELEASEKIT_GO_LOG": goLog,
"GOTOOLCHAIN": "local", "GOTOOLCHAIN": "local",
@@ -486,7 +525,7 @@ func executeWoodpeckerBlock(block, repository string, environment map[string]str
func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) { func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) {
t.Helper() t.Helper()
const module = "gitea.maximumdirect.net/eric/ReleaseKit" const module = "go.maximumdirect.net/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)) 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) writeRepositoryFile(t, repository, "go.mod", goMod)
cliGitRun(t, repository, "add", "go.mod") cliGitRun(t, repository, "add", "go.mod")
@@ -499,13 +538,13 @@ func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, i
func writeReleaseKitModuleProxy(t *testing.T) string { func writeReleaseKitModuleProxy(t *testing.T) string {
t.Helper() t.Helper()
const module = "gitea.maximumdirect.net/eric/ReleaseKit" const module = "go.maximumdirect.net/releasekit"
const toolDependency = "example.com/releasekit-helper" const toolDependency = "example.com/releasekit-helper"
proxyDir := t.TempDir() proxyDir := t.TempDir()
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n" 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"} { for _, version := range []string{"v0.2.197", "v0.2.198"} {
files := map[string]string{ 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", "cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; \"strconv\"; 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\tstatus, _ := strconv.Atoi(os.Getenv(\"RELEASEKIT_TOOL_EXIT\"))\n\tos.Exit(status)\n}\n",
} }
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files) writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
} }
@@ -606,16 +645,16 @@ func assertGoSelection(t *testing.T, repository, goLog string) {
t.Helper() t.Helper()
log := readWoodpeckerFixtureFile(t, goLog) log := readWoodpeckerFixtureFile(t, goLog)
list := "list -mod=mod -modfile=" list := "list -mod=mod -modfile="
build := "build -mod=mod -modfile=" tool := "tool -modfile="
module := "gitea.maximumdirect.net/eric/ReleaseKit" module := "go.maximumdirect.net/releasekit"
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) { 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) 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") { if !strings.Contains(log, tool) || !strings.Contains(log, " releasekit ci-deliver ") || strings.Contains(log, "@v0.2.0") {
t.Fatalf("Go build did not use the selected module without a version suffix: %q", log) t.Fatalf("Go tool did not run the module-selected ReleaseKit: %q", log)
} }
if strings.Index(log, list) > strings.Index(log, build) { if strings.Index(log, list) > strings.Index(log, tool) {
t.Fatalf("Go build ran before module selection: %q", log) t.Fatalf("Go tool ran before module selection: %q", log)
} }
var modfile string var modfile string
for _, field := range strings.Fields(log) { for _, field := range strings.Fields(log) {

View File

@@ -2,11 +2,15 @@ package releasekit
import ( import (
"context" "context"
"errors"
"fmt"
"io" "io"
"strings"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery" internaldelivery "go.maximumdirect.net/releasekit/internal/delivery"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "go.maximumdirect.net/releasekit/internal/forge"
"go.maximumdirect.net/releasekit/internal/gitea"
) )
// DeliveryState describes the result of one delivery invocation. // DeliveryState describes the result of one delivery invocation.
@@ -25,25 +29,73 @@ const (
DeliveryUnknown DeliveryState = "unknown" DeliveryUnknown DeliveryState = "unknown"
) )
// GiteaDestination supplies the hosted repository and explicit access token. // Forge selects the hosted delivery backend. A delivery request must select a
type GiteaDestination struct { // supported value explicitly; the destination URL does not select a backend.
type Forge string
const (
// ForgeGitea selects Gitea for hosted delivery.
ForgeGitea Forge = "gitea"
)
// DeliveryDestination supplies an explicitly selected hosted destination.
type DeliveryDestination struct {
// Forge selects the adapter that interprets the destination fields.
Forge Forge
BaseURL string BaseURL string
Owner string Owner string
Repository string Repository string
Token string Token string
} }
// DeliveryPreflightFailureKind classifies a failure while checking destination
// settings, credentials, and repository access.
type DeliveryPreflightFailureKind string
const (
// DeliveryPreflightInvalidDestination means the destination settings fail local validation.
DeliveryPreflightInvalidDestination DeliveryPreflightFailureKind = "invalid_destination"
// DeliveryPreflightMissingCredential means no nonblank credential was supplied.
DeliveryPreflightMissingCredential DeliveryPreflightFailureKind = "missing_credential"
// DeliveryPreflightInvalidCredential means the selected service rejected the credential.
DeliveryPreflightInvalidCredential DeliveryPreflightFailureKind = "invalid_credential"
// DeliveryPreflightInsufficientPermissions means a required delivery permission is known to be absent.
DeliveryPreflightInsufficientPermissions DeliveryPreflightFailureKind = "insufficient_permissions"
// DeliveryPreflightInaccessibleDestination means the credential cannot access the selected repository.
DeliveryPreflightInaccessibleDestination DeliveryPreflightFailureKind = "inaccessible_destination"
// DeliveryPreflightUnavailable means the service response could not verify access.
DeliveryPreflightUnavailable DeliveryPreflightFailureKind = "unavailable"
)
// DeliveryPreflightError reports a classified failure to verify a destination.
type DeliveryPreflightError struct {
Kind DeliveryPreflightFailureKind
Err error
}
func (err *DeliveryPreflightError) Error() string {
if err.Err == nil {
return fmt.Sprintf("delivery preflight %s", err.Kind)
}
return fmt.Sprintf("delivery preflight %s: %v", err.Kind, err.Err)
}
func (err *DeliveryPreflightError) Unwrap() error { return err.Err }
// DeliveryAsset identifies a hosted asset by filename and SHA-256 digest. // DeliveryAsset identifies a hosted asset by filename and SHA-256 digest.
type DeliveryAsset struct { type DeliveryAsset struct {
Filename string Filename string
SHA256 string SHA256 string
} }
// DeliveryRequest identifies a local tagged checkout and its explicit Gitea // DeliveryRequest identifies a local tagged checkout and its explicit
// destination. Build selects a managed build; Prebuilt supplies caller-owned // destination. Build selects a managed build; Prebuilt supplies caller-owned
// build output instead. They cannot both be set. Either may be nil for // build output instead. They cannot both be set. The ordinary page-only request
// page-only delivery when both are nil. Build with no targets, or an empty // leaves both nil. A Build with no targets or a matching Prebuilt result with
// Prebuilt result, also performs page-only delivery. // no targets or artifacts also performs page-only delivery. Any supplied
// Prebuilt must match the repository, module directory and path, tag, commit,
// and version; a zero BuildResult does not supply that identity. Source and
// artifact requirements for binaries apply when binary work is selected.
type DeliveryRequest struct { type DeliveryRequest struct {
RepositoryDir string // directory within the tagged source Git checkout. RepositoryDir string // directory within the tagged source Git checkout.
// ModuleDir selects a repository-relative module directory; empty selects // ModuleDir selects a repository-relative module directory; empty selects
@@ -54,7 +106,7 @@ type DeliveryRequest struct {
// ExpectedTag optionally requires the full CI event tag to match the // ExpectedTag optionally requires the full CI event tag to match the
// selected module's derived tag before building or making hosted requests. // selected module's derived tag before building or making hosted requests.
ExpectedTag string ExpectedTag string
Destination GiteaDestination Destination DeliveryDestination
// 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
// Prebuilt supplies an existing build whose files remain caller-owned. // Prebuilt supplies an existing build whose files remain caller-owned.
@@ -64,7 +116,8 @@ type DeliveryRequest struct {
} }
// DeliveryResult reports hosted content and the state reached by this // DeliveryResult reports hosted content and the state reached by this
// invocation. A partial result may accompany an error. The asset slices are // invocation. A result may accompany an error, including Complete when hosted
// delivery succeeded but local managed-build cleanup failed. The asset slices are
// 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 {
@@ -88,19 +141,22 @@ type DeliveryResult struct {
Uncertain []DeliveryAsset Uncertain []DeliveryAsset
} }
// Deliver verifies the selected local tag and hosted source, then creates or // Deliver requires an explicit supported destination forge. It checks the local
// finds its release page and adds any missing declared build assets. Build // request and source, then performs read-only credential preflight before a
// requests are compiled into temporary storage that Deliver removes on return. // managed build or full artifact verification. It verifies the selected local
// Prebuilt artifacts remain caller-owned and are not removed. Both paths // tag and hosted source, then creates or finds its release page and adds any
// validate source identity and artifact contents before hosted writes. // missing declared build assets. Prebuilt artifacts remain caller-owned. Local
// artifact contents are verified after preflight and before hosted writes.
// Module selection and declaration links resolve from the captured commit. // Module selection and declaration links resolve from the captured commit.
// Page-only delivery can use a sparse checkout with available committed objects; // Page-only delivery can use a sparse checkout with available committed objects;
// managed builds require their selected source files in the working tree. // managed builds require their selected source files in the working tree.
// Preflight failures return a [DeliveryPreflightError] and leave State at
// [DeliveryNotAttempted].
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{ backend, err := deliveryBackend(request.Destination)
BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner, if err != nil {
Repository: request.Destination.Repository, Token: request.Destination.Token, return result, err
} }
var prebuilt *internalbuild.Result var prebuilt *internalbuild.Result
if request.Prebuilt != nil { if request.Prebuilt != nil {
@@ -112,7 +168,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
ExpectedTag: request.ExpectedTag, ExpectedTag: request.ExpectedTag,
Destination: destination, Backend: backend,
Build: internalBuildSpec(request.Build), Build: internalBuildSpec(request.Build),
Prebuilt: prebuilt, Prebuilt: prebuilt,
Output: request.Output, Output: request.Output,
@@ -129,10 +185,65 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
result.Pending = deliveryAssets(internalResult.Pending) result.Pending = deliveryAssets(internalResult.Pending)
result.Conflicting = deliveryAssets(internalResult.Conflicting) result.Conflicting = deliveryAssets(internalResult.Conflicting)
result.Uncertain = deliveryAssets(internalResult.Uncertain) result.Uncertain = deliveryAssets(internalResult.Uncertain)
return result, err return result, publicPreflightError(err)
} }
func deliveryAssets(assets []gitea.AssetIdentity) []DeliveryAsset { // PreflightDelivery checks destination credentials and repository access using
// read-only backend operations. It does not require a checkout or published tag.
// The result verifies only the access and permission evidence exposed by the
// selected backend; it does not guarantee that a later write will succeed.
// Failures are returned as [DeliveryPreflightError] values when classified by
// the backend, and retain their underlying cause for errors.Is and errors.As.
func PreflightDelivery(ctx context.Context, destination DeliveryDestination) error {
backend, err := deliveryBackend(destination)
if err != nil {
return err
}
return publicPreflightError(backend.Preflight(ctx))
}
func deliveryBackend(destination DeliveryDestination) (forge.Backend, error) {
selection := Forge(strings.TrimSpace(string(destination.Forge)))
switch selection {
case ForgeGitea:
return gitea.NewBackend(gitea.Destination{
BaseURL: destination.BaseURL, Owner: destination.Owner,
Repository: destination.Repository, Token: destination.Token,
}, nil), nil
case "":
return nil, errors.New("delivery forge must be selected explicitly")
default:
return nil, fmt.Errorf("unsupported delivery forge %q", destination.Forge)
}
}
func publicPreflightError(err error) error {
if err == nil {
return nil
}
var failure *forge.PreflightFailure
if !errors.As(err, &failure) {
return err
}
kind := DeliveryPreflightUnavailable
switch failure.Kind {
case forge.PreflightInvalidDestination:
kind = DeliveryPreflightInvalidDestination
case forge.PreflightMissingCredential:
kind = DeliveryPreflightMissingCredential
case forge.PreflightInvalidCredential:
kind = DeliveryPreflightInvalidCredential
case forge.PreflightInsufficientPermissions:
kind = DeliveryPreflightInsufficientPermissions
case forge.PreflightInaccessibleDestination:
kind = DeliveryPreflightInaccessibleDestination
case forge.PreflightUnavailable:
kind = DeliveryPreflightUnavailable
}
return &DeliveryPreflightError{Kind: kind, Err: failure.Err}
}
func deliveryAssets(assets []internaldelivery.AssetIdentity) []DeliveryAsset {
result := make([]DeliveryAsset, 0, len(assets)) result := make([]DeliveryAsset, 0, len(assets))
for _, asset := range assets { for _, asset := range assets {
result = append(result, DeliveryAsset{Filename: asset.Filename, SHA256: asset.SHA256}) result = append(result, DeliveryAsset{Filename: asset.Filename, SHA256: asset.SHA256})

63
delivery_causes_test.go Normal file
View File

@@ -0,0 +1,63 @@
package releasekit_test
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
releasekit "go.maximumdirect.net/releasekit"
)
func TestDeliveryPreservesAssetInspectionCauses(t *testing.T) {
repository := newTaggedDeliveryConsumer(t)
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
})
if err != nil {
t.Fatal(err)
}
filename := prebuilt.Artifacts[0].Filename
for _, cause := range []error{context.Canceled, io.ErrUnexpectedEOF} {
t.Run(cause.Error(), func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
state := &facadeDeliveryServer{data: facadeDeliveryData{
t: t, commit: prebuilt.CandidateCommit, tag: prebuilt.Tag, version: prebuilt.Version,
noteBody: "# Fixture v1.2.3\n\nDelivery fixture.\n",
pages: map[string]bool{prebuilt.Tag: true}, assets: map[string][]byte{filename: []byte("data")},
}}
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/files/") {
if cause == context.Canceled {
cancel()
<-r.Context().Done()
return
}
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("truncated"))
return
}
state.serveHTTP(w, r)
}))
state.data.url = "http://" + server.Listener.Addr().String()
server.Start()
defer server.Close()
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3",
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &prebuilt,
})
if !errors.Is(err, cause) || !strings.Contains(err.Error(), filename) {
t.Fatalf("Deliver() error = %v, want cause %v and asset context", err, cause)
}
if result.State != releasekit.DeliveryNotAttempted || len(result.Uncertain) != 1 || result.Uncertain[0].Filename != filename || state.snapshot().writes != 0 {
t.Fatalf("result = %#v, want read-only uncertain asset without write", result)
}
})
}
}

View File

@@ -0,0 +1,53 @@
package releasekit_test
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
releasekit "go.maximumdirect.net/releasekit"
)
func TestInvalidDestinationFailsStandaloneAndAutomaticPreflight(t *testing.T) {
repository := newTaggedDeliveryConsumer(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("invalid destination made a hosted request: %s", r.URL.Path)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
defer server.Close()
for _, test := range []struct {
name, setting string
change func(*releasekit.DeliveryDestination)
}{
{"URL", "base URL", func(d *releasekit.DeliveryDestination) { d.BaseURL = "ssh://forge.example" }},
{"owner", "owner", func(d *releasekit.DeliveryDestination) { d.Owner = "owner/child" }},
{"repository", "repository", func(d *releasekit.DeliveryDestination) { d.Repository = "repo/child" }},
} {
t.Run(test.name, func(t *testing.T) {
destination := facadeDeliveryDestination(server.URL)
test.change(&destination)
assertFailure := func(err error) {
t.Helper()
var preflight *releasekit.DeliveryPreflightError
if !errors.As(err, &preflight) || preflight.Kind != releasekit.DeliveryPreflightInvalidDestination || preflight.Err == nil {
t.Fatalf("error = %#v, want invalid destination with cause", err)
}
if !strings.Contains(err.Error(), test.setting) {
t.Fatalf("error = %v, want invalid setting %q", err, test.setting)
}
}
assertFailure(releasekit.PreflightDelivery(context.Background(), destination))
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: destination,
Build: &releasekit.BuildSpec{MainPackage: "./missing", BinaryName: "fixture", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}},
})
assertFailure(err)
if result.State != releasekit.DeliveryNotAttempted {
t.Fatalf("delivery state = %s, want not attempted", result.State)
}
})
}
}

View File

@@ -1,67 +0,0 @@
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)
}
}

View File

@@ -16,9 +16,178 @@ import (
"sync" "sync"
"testing" "testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "go.maximumdirect.net/releasekit"
) )
func TestDeliveryForgeSelectionIsExplicit(t *testing.T) {
var requests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
defer server.Close()
for _, forge := range []releasekit.Forge{"", " \t", "unknown"} {
t.Run(string(forge), func(t *testing.T) {
destination := releasekit.DeliveryDestination{
Forge: forge, BaseURL: server.URL, Owner: "owner", Repository: "example", Token: "fixture-token",
}
if err := releasekit.PreflightDelivery(context.Background(), destination); err == nil || !strings.Contains(err.Error(), "forge") {
t.Fatalf("PreflightDelivery() error = %v, want explicit forge selection error", err)
}
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
Version: "v1.2.3", Destination: destination,
})
if err == nil || !strings.Contains(err.Error(), "forge") || result.State != releasekit.DeliveryNotAttempted || result.Version != "v1.2.3" {
t.Fatalf("Deliver() = %#v, %v; want selection error before delivery", result, err)
}
})
}
if requests != 0 {
t.Fatalf("hosted requests = %d, want 0 for invalid forge selection", requests)
}
}
func TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout(t *testing.T) {
var requests, writes int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writes++
t.Errorf("preflight method = %q, want GET", r.Method)
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
return
}
if got, want := r.Header.Get("Authorization"), "token fixture-token"; got != want {
t.Errorf("authorization = %q, want %q", got, want)
}
requests++
switch r.URL.Path {
case "/api/v1/repos/owner/example":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
case "/api/v1/repositories/31":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
default:
t.Errorf("preflight path = %q", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
if err := releasekit.PreflightDelivery(context.Background(), facadeDeliveryDestination(server.URL)); err != nil {
t.Fatalf("PreflightDelivery() error = %v", err)
}
if requests != 2 || writes != 0 {
t.Fatalf("preflight requests=%d writes=%d, want two reads and no writes", requests, writes)
}
}
func TestPreflightDeliveryPreservesPublicFailureKindsAndCauses(t *testing.T) {
cases := []struct {
name string
token string
status int
wantKind releasekit.DeliveryPreflightFailureKind
wantReads int
canceled bool
}{
{name: "missing credential", wantKind: releasekit.DeliveryPreflightMissingCredential},
{name: "invalid credential", token: "fixture-token", status: http.StatusUnauthorized, wantKind: releasekit.DeliveryPreflightInvalidCredential, wantReads: 1},
{name: "inaccessible destination", token: "fixture-token", status: http.StatusForbidden, wantKind: releasekit.DeliveryPreflightInaccessibleDestination, wantReads: 1},
{name: "unavailable service", token: "fixture-token", status: http.StatusServiceUnavailable, wantKind: releasekit.DeliveryPreflightUnavailable, wantReads: 1},
{name: "canceled request", token: "fixture-token", wantKind: releasekit.DeliveryPreflightUnavailable, canceled: true},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
reads := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reads++
if test.status != 0 {
http.Error(w, "controlled failure", test.status)
return
}
t.Errorf("unexpected request to %q", r.URL.Path)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
defer server.Close()
destination := facadeDeliveryDestination(server.URL)
destination.Token = test.token
ctx := context.Background()
if test.canceled {
canceled, cancel := context.WithCancel(ctx)
cancel()
ctx = canceled
}
err := releasekit.PreflightDelivery(ctx, destination)
var preflight *releasekit.DeliveryPreflightError
if !errors.As(err, &preflight) || preflight.Kind != test.wantKind || preflight.Err == nil {
t.Fatalf("PreflightDelivery() error = %#v, want kind %q with cause", err, test.wantKind)
}
if test.canceled && !errors.Is(err, context.Canceled) {
t.Fatalf("PreflightDelivery() error = %v, want cancellation identity", err)
}
if reads != test.wantReads {
t.Fatalf("preflight reads = %d, want %d", reads, test.wantReads)
}
})
}
}
func TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing(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: t.TempDir(),
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
contents, err := os.ReadFile(prebuilt.Artifacts[0].Path)
if err != nil {
t.Fatal(err)
}
contents[0] ^= 0xff
if err := os.WriteFile(prebuilt.Artifacts[0].Path, contents, 0o600); err != nil {
t.Fatal(err)
}
var reads, writes int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writes++
http.Error(w, "unexpected write", http.StatusMethodNotAllowed)
return
}
reads++
if r.URL.Path == "/api/v1/repos/owner/example" {
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "example", "full_name": "owner/example", "owner": map[string]string{"login": "owner"}})
return
}
http.Error(w, "verification unavailable", http.StatusServiceUnavailable)
}))
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,
})
var preflight *releasekit.DeliveryPreflightError
if !errors.As(err, &preflight) || preflight.Kind != releasekit.DeliveryPreflightUnavailable || result.State != releasekit.DeliveryNotAttempted {
t.Fatalf("Deliver() = %#v, %v; want unavailable preflight before tamper verification", result, err)
}
if reads != 2 || writes != 0 {
t.Fatalf("preflight reads=%d writes=%d, want two reads and no writes", reads, writes)
}
}
func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) { func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
temporaryRoot := t.TempDir() temporaryRoot := t.TempDir()
@@ -138,6 +307,34 @@ func TestDeliverPrebuiltArtifactsRemainCallerOwned(t *testing.T) {
} }
} }
func TestDeliverRejectsOversizedExistingPrebuiltAsset(t *testing.T) {
repository := newTaggedDeliveryConsumer(t)
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(prebuilt.Artifacts[0].Path)
if err != nil {
t.Fatal(err)
}
server, remote := newFacadeDeliveryServer(t, repository)
defer server.Close()
remote.update(func(data *facadeDeliveryData) {
data.pages["v1.2.3"] = true
data.assets[prebuilt.Artifacts[0].Filename] = append(content, 'x')
})
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 || result.State != releasekit.DeliveryConflict || len(result.Conflicting) != 1 || result.Conflicting[0].Filename != prebuilt.Artifacts[0].Filename || remote.snapshot().writes != 0 {
t.Fatalf("delivery = %#v, error = %v, writes = %d", result, err, remote.snapshot().writes)
}
}
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) { func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
@@ -177,8 +374,8 @@ func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T)
if err == nil || !strings.Contains(err.Error(), "verify build assets") || result.State != releasekit.DeliveryNotAttempted { 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) t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err)
} }
if remote.snapshot().requests != 0 { if remote.snapshot().writes != 0 {
t.Fatalf("hosted requests = %d, want none before local verification", remote.snapshot().requests) t.Fatalf("hosted writes = %d, want none before local verification", remote.snapshot().writes)
} }
} }
@@ -233,6 +430,29 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
} }
} }
func TestDeliverUsesOriginalNoteWithBlobReplacement(t *testing.T) {
repository := newTaggedDeliveryConsumer(t)
original := buildGit(t, repository, "rev-parse", "HEAD:docs/releases/v1.2.3.md")
replacementPath := filepath.Join(t.TempDir(), "note.md")
if err := os.WriteFile(replacementPath, []byte("# Fixture v1.2.3\n\nReplacement note.\n"), 0o600); err != nil {
t.Fatal(err)
}
replacement := buildGit(t, repository, "hash-object", "-w", replacementPath)
buildGit(t, repository, "replace", original, replacement)
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),
})
if err != nil || result.State != releasekit.DeliveryComplete {
t.Fatalf("delivery = %#v, error = %v", result, err)
}
if got := remote.snapshot().createdBody; got != "# Fixture v1.2.3\n\nDelivery fixture.\n" {
t.Fatalf("delivered note = %q", got)
}
}
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) { func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
repository := newTwoModuleDeliveryConsumer(t) repository := newTwoModuleDeliveryConsumer(t)
server, remote := newFacadeDeliveryServer(t, repository) server, remote := newFacadeDeliveryServer(t, repository)
@@ -323,7 +543,7 @@ func newTwoModuleDeliveryConsumer(t *testing.T) string {
import ( import (
"fmt" "fmt"
"os" "os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } } func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
`) `)
@@ -463,8 +683,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.snapshot().requests != 0 { if remote.snapshot().writes != 0 {
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests) t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
} }
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)
@@ -492,8 +712,8 @@ func TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites(t *te
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().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.snapshot().requests != 0 { if remote.snapshot().writes != 0 {
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests) t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
} }
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)
@@ -525,8 +745,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.snapshot().requests != 0 { if remote.snapshot().writes != 0 {
t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests) t.Fatalf("hosted writes = %d, want none after preflight", remote.snapshot().writes)
} }
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)
@@ -564,7 +784,7 @@ func newTaggedDeliveryConsumerWithNote(t *testing.T, note string) string {
import ( import (
"fmt" "fmt"
"os" "os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } } func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
`) `)
@@ -575,8 +795,8 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fix
return repository return repository
} }
func facadeDeliveryDestination(baseURL string) releasekit.GiteaDestination { func facadeDeliveryDestination(baseURL string) releasekit.DeliveryDestination {
return releasekit.GiteaDestination{BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"} return releasekit.DeliveryDestination{Forge: releasekit.ForgeGitea, BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"}
} }
type deliveryCancelWriter struct{ cancel context.CancelFunc } type deliveryCancelWriter struct{ cancel context.CancelFunc }
@@ -586,6 +806,33 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
return len(content), nil return len(content), nil
} }
func TestDeliverExistingPageWithoutReleaseID(t *testing.T) {
repository := newTaggedDeliveryConsumer(t)
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(),
Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}},
})
if err != nil {
t.Fatal(err)
}
server, remote := newFacadeDeliveryServer(t, repository)
defer server.Close()
remote.update(func(data *facadeDeliveryData) { data.pages[data.tag] = true; data.omitReleaseID = true })
request := releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Destination: facadeDeliveryDestination(server.URL)}
page, err := releasekit.Deliver(context.Background(), request)
if err != nil || page.State != releasekit.DeliveryComplete {
t.Fatalf("page result = %#v, error = %v", page, err)
}
request.Prebuilt = &prebuilt
result, err := releasekit.Deliver(context.Background(), request)
if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "release ID") {
t.Fatalf("asset result = %#v, error = %v", result, err)
}
if snapshot := remote.snapshot(); snapshot.writes != 0 {
t.Fatalf("unexpected hosted writes: %#v", snapshot)
}
}
type facadeDeliveryServer struct { type facadeDeliveryServer struct {
mu sync.Mutex mu sync.Mutex
data facadeDeliveryData data facadeDeliveryData
@@ -616,15 +863,18 @@ func (state *facadeDeliveryServer) snapshot() facadeDeliveryData {
} }
type facadeDeliveryData struct { type facadeDeliveryData struct {
t *testing.T t *testing.T
commit string commit string
tag string tag string
version string version string
noteBody string noteBody string
pages map[string]bool createdBody string
requests int pages map[string]bool
assets map[string][]byte requests int
url string writes int
omitReleaseID bool
assets map[string][]byte
url string
} }
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) { func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
@@ -642,11 +892,19 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
data := &state.data data := &state.data
if strings.HasPrefix(r.URL.Path, "/api/") { if strings.HasPrefix(r.URL.Path, "/api/") {
data.requests++ data.requests++
if r.Method != http.MethodGet {
data.writes++
}
if r.Header.Get("Authorization") != "token fixture-token" { if r.Header.Get("Authorization") != "token fixture-token" {
data.t.Errorf("authorization = %q", r.Header.Get("Authorization")) data.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
} }
} }
switch r.URL.EscapedPath() { switch r.URL.EscapedPath() {
case "/api/v1/repos/owner/example", "/api/v1/repositories/1":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 1, "name": "example", "full_name": "owner/example",
"owner": map[string]string{"login": "owner"},
})
case "/api/v1/repos/owner/example/tags/" + url.PathEscape(data.tag): case "/api/v1/repos/owner/example/tags/" + url.PathEscape(data.tag):
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit) _, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit)
case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(data.tag): case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(data.tag):
@@ -670,6 +928,7 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version { if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
data.t.Errorf("release payload = %#v", payload) data.t.Errorf("release payload = %#v", payload)
} }
data.createdBody = payload.Body
data.pages[data.tag] = 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":
@@ -716,11 +975,15 @@ func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
// The request handler holds state.mu. // The request handler holds state.mu.
data := &state.data data := &state.data
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ release := map[string]any{
"id": 10, "html_url": data.url + "/releases/10", "tag_name": data.tag, "html_url": data.url + "/releases/10", "tag_name": data.tag,
"target_commitish": data.commit, "name": "Fixture " + data.version, "target_commitish": data.commit, "name": "Fixture " + data.version,
"body": data.noteBody, "draft": false, "prerelease": false, "body": data.noteBody, "draft": false, "prerelease": false,
}) }
if !data.omitReleaseID {
release["id"] = 10
}
_ = json.NewEncoder(w).Encode(release)
} }
func mapKeys(values map[string][]byte) []string { func mapKeys(values map[string][]byte) []string {

View File

@@ -0,0 +1,92 @@
# ADR 0006: Separate delivery policy from forge protocols
## Status
Accepted.
## Date
2026-11-01
## Context
ReleaseKit's delivery workflow handles release-page matching and creation
recovery, remote asset comparison, upload ordering, retries, and partial or
uncertain outcomes. Those policies are useful independently of the Gitea HTTP
protocol, but the earlier client-owned flow coupled them to Gitea types and
operations.
Delivery also benefits from checking credentials and repository access before
managed builds, dependency preparation, or complete artifact hashing. Gitea
exposes authenticated repository reads, but its `permissions.push` metadata and
basic token evidence do not establish permission to write release pages or
assets. A read-only check must represent that evidence limit without claiming
that later writes are guaranteed.
The root `releasekit` facade owns public types, while implementation packages
remain internal. The CLI owns credential loading and process exit status.
Source publication has separate Git authentication and completion semantics.
## Decision
The root facade owns `Forge`, `DeliveryDestination`, explicit backend selection,
and public preflight errors. `internal/forge` defines the internal backend
contract and neutral release, asset, failure, and write-evidence values.
`internal/delivery` owns shared delivery coordination and policy.
`internal/gitea` owns Gitea destination rules, authenticated preflight requests,
protocol operations, codecs, handles, streaming, and conversion to neutral
failures. `internal/artifact` owns local asset declarations and verification.
Shared delivery, forge, and artifact packages have no production dependency
path to the Gitea adapter.
Delivery selection is explicit. The supported value is `gitea`; neither a URL
nor an omitted selection chooses a backend. There is no compatibility alias for
the removed public Gitea destination type.
The facade exposes `PreflightDelivery` and runs the same read-only backend
preflight automatically during `Deliver`. Gitea preflight authenticates through
repository-scoped reads and classifies missing or rejected credentials,
inaccessible destinations, and unavailable verification. It does not issue a
test release or upload, require extra user/admin token scopes, or treat
unexposed write permission evidence as a failure. Success confirms only the
authentication, repository access, and permission evidence available through
that check; ordinary writes remain authoritative. Preflight failures preserve
their causes and leave delivery `not_attempted`.
The CLI requires `delivery.forge` only for `deliver` and `ci-deliver`. Binary
CI delivery performs a standalone preflight after checkout/profile/token
preparation and before application dependency downloads, then `Deliver`
rechecks. Page-only CI relies on automatic delivery preflight. `check`,
`build`, and source `publish` do not require delivery configuration or hosted
credentials. Local artifact verification follows preflight but completes before
hosted writes.
## Alternatives considered
- Keep delivery policy in the Gitea client. This retains protocol coupling and
makes shared recovery behavior harder to exercise without the adapter.
- Select Gitea from the URL or preserve the old Gitea-specific facade type.
This hides backend selection and keeps the public contract tied to one
adapter.
- Probe permissions with a test release or asset, or require additional user
scopes. Those operations can create remote state or demand evidence unrelated
to release delivery; the repository read does not expose the required
release-write proof.
- Skip preflight and let the first write discover credential problems. That
delays a known failure until after costly local work and provides no
standalone check for callers preparing their own delivery.
## Consequences
- Shared policy can be tested with controlled backends, while local HTTP tests
focus on Gitea protocol behavior and assembled integration flows.
- Consumers must replace the removed Gitea-specific destination type, set
`ForgeGitea` in API calls, and add `"forge": "gitea"` to delivery profiles.
- Delivery may make additional authenticated read requests before local
artifact hashing. It still verifies full local content before any hosted
write.
- Credential, access, and availability failures have distinct public error
kinds and CLI classifications. Successful preflight does not certify future
release or asset writes.
- Source publication retains native Git authentication and is not gated by
hosted delivery preflight.

View File

@@ -1,6 +1,6 @@
# Command line # Command line
ReleaseKit currently provides six commands. The exit statuses below belong ReleaseKit currently provides eight commands. The exit statuses below belong
to the native executable. `go run` examples are convenient for source execution, to the native executable. `go run` examples are convenient for source execution,
but the Go driver maps a nonzero child exit status to 1. Use the built executable but the Go driver maps a nonzero child exit status to 1. Use the built executable
or `./scripts/release` for publication when distinct statuses are needed. or `./scripts/release` for publication when distinct statuses are needed.
@@ -75,7 +75,7 @@ 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] [--expected-module DIR] VERSION go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] [--record-dir DIR] VERSION
``` ```
`publish` reads the same product, remote, validation, and additional-check `publish` reads the same product, remote, validation, and additional-check
@@ -91,7 +91,8 @@ and pushes only its exact full tag object. It prints the confirmed semantic
version, full tag, candidate commit, and tag object 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. HTTP(S) endpoint
userinfo is masked in diagnostics. 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 Shared validation failures use the same check and finding presentation as
@@ -137,13 +138,76 @@ 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 rewrites are resolved first, and the push URL is used when fetch and push URLs
differ. `check` ignores this field. differ. `check` ignores this field.
The command automatically saves available publication evidence in the repository's
private attempt store. Use `--record-dir DIR` to select another directory; a
relative override is resolved from the source repository root. Standard error
reports the absolute saved record path or a recording warning separately from
the publication result. Recording failure does not change publication status.
When publication fails and a record was saved, the CLI also prints a copyable
`reconcile --record` command for that exact record. The command selects the
consumer module's registered Go tool when available. API-only consumers receive
a Go-tool invocation with durable private module files; see
[recovery storage](consumers/publish.md#retain-and-reconcile-publication-evidence).
Consumers without a module-selected ReleaseKit dependency use the running
executable's absolute path. A module-preparation failure reports a warning and
uses a persistent native executable when available. Recognized temporary Go-build
executables provide no native fallback after their driver exits. Command
preparation has its own bounded context after a failed publication, so publication
cancellation permits preparing the later invocation. Each entry point preserves
the recovery exit status.
If the selected entry point becomes unavailable, use another supported ReleaseKit
CLI with the saved record.
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
errors, and 3 when the push outcome remains unknown. Unknown status must be errors, and 3 when the push outcome remains unknown. Unknown status must be
resolved against the authoritative remote before retrying. The resolved with the explicit-record recovery operation below before retrying. The
[maintainer procedure](release.md) selects the repository profile and gives [maintainer procedure](release.md) selects the repository profile and gives
the source-release workflow. the source-release workflow.
## Recover a saved publication attempt or discover local attempts
```sh
releasekit reconcile --record FILE [--json]
releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]
```
`reconcile` requires one explicitly selected record and reads a relative `FILE`
from the invoking directory. It observes the endpoint and tag stored in that
record; it does not load a profile, inspect the current candidate or local tag,
run checks, or change repository state. `publication-attempts` lists records in
the selected store without querying remotes or choosing an attempt. An omitted
`--repo` means the current directory. Its relative `--record-dir` is resolved
from the selected repository root; an absolute directory can be listed outside
a checkout. The selected directory must already exist. Discovery reports usable
records and per-record diagnostics together when some records cannot be read.
By default, both operations print human-readable output. `--json` writes one
structured document to standard output after valid arguments, including for
reconciliation and discovery failures. Recovery does not run candidate checks
or make hosted delivery requests. It reads the record's captured endpoint, while
using current supported transport settings for its recorded remote name.
| Recovery command result | Status |
| --- | --- |
| Reconciliation observes the intended publication | 0 |
| Reconciliation observes absence or conflict | 1 |
| Reconciliation cannot read or decode the selected record, or has an invocation error | 2 |
| Reconciliation remains unknown, including missing evidence or cancellation | 3 |
| Discovery completes without record diagnostics, including an existing empty store | 0 |
| Discovery is incomplete or fails, including cancellation | 1 |
| Discovery argument or usage error | 2 |
| Either command cannot write its JSON output | 1 |
A JSON write failure reports a local output diagnostic on standard error. This
status describes reporting failure and leaves the observed publication state
unchanged.
The CLI JSON schema and safe evidence fields are documented in the
[publication record format](integrations/publication-records.md). Recovery
guidance for storage, older evidence, and retry decisions is in the
[maintainer procedure](release.md#resolve-publication-failures).
Build selected application targets: Build selected application targets:
```sh ```sh
@@ -168,14 +232,17 @@ tree and does not perform candidate validation or remote publication. `check`
and `publish` run the same configured build after their one candidate and `publish` run the same configured build after their one candidate
validation, using temporary output outside the worktree. validation, using temporary output outside the worktree.
Deliver a tagged source release to Gitea: Deliver a tagged source release:
```sh ```sh
go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION
``` ```
`deliver` requires a `delivery` profile object and its configured token `deliver` requires a `delivery` profile object with an explicit
environment variable, which defaults to `RELEASEKIT_GITEA_TOKEN`. It verifies `"forge": "gitea"` selection and a token loaded from its configured environment
variable or token file. The destination URL does not select a forge. Selection
errors are reported before token access or delivery work. Credential selection
and errors are defined in the [configuration reference](config.md). It verifies
the local tag, clean tagged checkout, hosted tag, and committed release note, the local tag, clean tagged checkout, hosted tag, and committed release note,
then creates or finds the release page and reports delivery state separately then creates or finds the release page and reports delivery state separately
from source publication. Historical lightweight tags and detached checkouts from source publication. Historical lightweight tags and detached checkouts
@@ -194,16 +261,28 @@ 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.
After cheap local checks, delivery performs authenticated, read-only repository
preflight before a managed build or full artifact size and digest verification.
An unavailable preflight leaves delivery `not_attempted`; after preflight
succeeds, local artifact verification still finishes before any hosted write.
Use one `deliver` invocation for the normal workflow. To build without delivery Use one `deliver` invocation for the normal workflow. To build without delivery
credentials, or to retain build output across retries, run `build --output DIR` credentials, or to retain build output across retries, run `build --output DIR`
first and then run `deliver --assets DIR` with the token available. The ordinary first and then run `deliver --assets DIR` with the token available. The ordinary
`deliver` command does not download application dependencies or change the `deliver` command does not download application dependencies or change the
checkout. checkout.
The command returns 0 for complete delivery, 1 for a known failure, conflict, The command returns 0 for complete delivery without a local error, 1 for an
or partial delivery, 2 for usage, profile, or missing-credential errors, and 3 unavailable preflight, a known delivery failure, conflict, partial delivery, or
when a remote write remains uncertain. The result lists matching, uploaded, local cleanup failure after hosted completion, and 2 for usage,
pending, conflicting, and uncertain assets as disjoint current observations; profile, forge-selection, local credential-loading, or classified preflight
configuration/access errors. A remote write whose outcome remains uncertain
returns 3, including when cancellation prevents reconciliation. Cancellation
without an unresolved write returns 1, including during preflight or
preparation. Later authorization failures follow
the delivery outcome: known failure returns 1; an unresolved write returns 3.
The result lists matching, uploaded, pending, conflicting, and uncertain assets
as disjoint current observations;
early failures may leave some declared assets unreported. On failure, the CLI early failures may leave some declared assets unreported. On failure, the CLI
prints state-based recovery guidance. Fix local or preflight failures before prints state-based recovery guidance. Fix local or preflight failures before
retrying; fix the reported cause before retrying partial delivery; resolve an retrying; fix the reported cause before retrying partial delivery; resolve an
@@ -228,12 +307,16 @@ the selected Git remote (default `origin`), requires its peeled commit to match
the required full `--commit` object ID, checks out that commit detached, then the required full `--commit` object ID, checks out that commit detached, then
verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
checks read-only against the existing checkout and does not fetch or check out. checks read-only against the existing checkout and does not fetch or check out.
Preparation diagnostics mask userinfo in HTTP(S) remote URLs.
In either mode, it loads the profile from the verified source. `--expected-module` 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; optionally checks that the profile selects the module chosen by a launcher;
module selection itself remains profile-owned. The command validates delivery module selection itself remains profile-owned. The command compares the selected
settings and the configured token, then downloads application dependencies only module's derived release tag with the full event tag before forge access or
when the profile selects build targets. Binary profiles are built and delivered dependency preparation, then validates explicit forge selection and resolves
through the same facade path as `deliver`; source-only profiles skip dependency the configured token. For binary profiles, it performs a read-only repository
preflight before downloading application
dependencies; delivery performs a fresh check after dependency preparation.
Source-only profiles rely on delivery's automatic preflight and skip dependency
downloads and binary builds. 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. The default mode prepares and from `--repo`, and absolute paths are used as-is. The default mode prepares and
@@ -241,18 +324,23 @@ changes the selected checkout; `--prepared` reuses a verified checkout.
Ordinary `deliver` continues to operate on already prepared source without Ordinary `deliver` continues to operate on already prepared source without
fetching, checking out, or 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 full tag 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, unavailable preflight, and cancellation during those
version errors return status 2. Final delivery uses the same statuses as steps return status 1. Usage, profile, forge-selection, local credential-loading,
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery, classified preflight configuration/access, and invalid-version errors return
and 3 for uncertain remote state. status 2. Final delivery uses the same statuses as `deliver`: 0 for complete,
1 for known failure, conflict, or partial delivery, and 3 for uncertain remote
state, including when cancellation prevents reconciliation.
The maintained module-based tag workflow verifies and checks out the event tag The maintained module-based tag workflow verifies and checks out the event tag
before resolving the consumer module. It builds the ReleaseKit version already before resolving the consumer module. It runs `go tool releasekit` for the version
selected by that tagged `go.mod`, using temporary alternate module files outside already selected by that tagged `go.mod`, using temporary alternate module files
the source checkout. This tool bootstrap is separate from `ci-deliver`'s outside the source checkout. This tool bootstrap is separate from `ci-deliver`'s
application dependency preparation for binary profiles. ReleaseKit's own application dependency preparation for binary profiles. ReleaseKit's own
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared
ReleaseKit module dependency can use the separately installed-tool command. ReleaseKit module dependency can use the separately installed-tool command.

View File

@@ -16,7 +16,7 @@ The current profile fields are:
| `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. | | `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 selected forge and destination fields for tagged-source delivery. |
`module_dir` names the selected module's physical location in the repository; `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 ReleaseKit reads its declared import path from that module's own `go.mod`. The
@@ -141,12 +141,52 @@ 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, the captured candidate is checked, one offending file is reported at a time,
and historical or aggregate repository size is not measured. and historical or aggregate repository size is not measured.
The `delivery` object contains `base_url`, `owner`, `repository`, and optional `validation.forbidden_files` is an optional list of literal repository-relative
`token_env`. Its values are required when running `deliver`, but credentials are file paths that must be absent from the captured commit. It runs after the Go
not required for `check`, `publish`, or `build`. `token_env` names the checks and before other file checks, using the `forbidden-files` identity and
environment variable containing the Gitea token; it defaults to reporting selected committed paths. Omission, `null`, or `[]` disables it.
`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and Paths use slash separators and ordinary relative normalization and must stay
passes its value explicitly to the library operation. within the repository. They are checked against the whole captured repository,
independently of the selected module and `shared_files`. The check reads path
metadata only; it does not read token contents, follow symlink targets, require
local files to exist, or inspect Git history. Existing clean-index/worktree
requirements still apply to candidate operations. An ignore rule alone does
not remove a previously committed file from the candidate.
The `delivery` object contains `forge`, `base_url`, `owner`, `repository`, and
optional `token_env` and `token_file`. A delivery request must set `forge` to
`"gitea"`; omission, an empty or whitespace-only value, and unsupported values
are errors when `deliver` or `ci-deliver` is requested. The URL does not select
the forge. These semantic checks run only for delivery, so `check`, `publish`,
and `build` do not require a delivery object, selection, or credentials.
`token_env` names the environment variable containing the Gitea token and
defaults to `RELEASEKIT_GITEA_TOKEN`. A nonblank environment value takes
precedence. When it is unset or blank, `token_file` selects a plain
token-containing file; relative paths resolve from the Git repository root,
and absolute paths are used as supplied. The CLI trims leading and trailing
whitespace from file contents. An unreadable or blank file, or the absence of
both credential sources, returns status 2. Omission of `token_file` retains
environment-only loading.
After resolving the token, delivery performs an authenticated, read-only
repository access check. Invalid destination settings, missing or rejected
credentials, known insufficient permissions, and inaccessible destinations are
configuration/credential failures. An unavailable service or canceled check is
an operational failure.
See the [CLI contract](cli.md) for exact exit statuses. Successful preflight
confirms authentication and repository access and accounts for any exposed
delivery-permission deficiency. It does not guarantee later release or asset
writes. Local artifact size and digest checks follow preflight and still finish
before hosted writes.
Credential loading runs only for `deliver` and `ci-deliver`. `check`, `publish`,
and `build` do not read the token file or require a token. The CLI passes the
resolved value explicitly to the library operation. `ci-deliver` checks the
destination before downloading application dependencies for a binary build;
the later delivery performs a fresh check. Page-only CI relies on delivery's
automatic check. Selecting a token file and selecting the optional
forbidden-file validator are independent settings.
`expected_push_url`, when nonempty, is compared exactly with Git's resolved `expected_push_url`, when nonempty, is compared exactly with Git's resolved
effective push URL during `publish`, before candidate validation. It applies effective push URL during `publish`, before candidate validation. It applies

View File

@@ -10,12 +10,14 @@ From the consumer module's root, select the ReleaseKit version to adopt:
```sh ```sh
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}" : "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
go get -tool "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}" go get -tool "go.maximumdirect.net/releasekit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
go mod tidy go mod tidy
go tool releasekit version go tool releasekit version
``` ```
Select a release that implements the API, profile, and workflow you are adopting. Select a release that implements the API, profile, and workflow you are adopting.
The vanity module path starts with v0.5.0. Versions through v0.4.2 declare the
original module path and use their corresponding historical instructions.
The module-release interface requires a release containing that feature; The module-release interface requires a release containing that feature;
pre-feature versions through v0.3.0 use their corresponding historical interface. pre-feature versions through v0.3.0 use their corresponding historical interface.
Go records the executable package in Go records the executable package in
@@ -32,8 +34,21 @@ Go minimum. See the
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool). [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 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, version. Go builds and caches the executable and preserves its exit status.
and the [configuration reference](../config.md) for the release profile. A launcher invoked from that module can forward commands directly:
```sh
#!/bin/sh
exec go tool releasekit "$@"
```
For a launcher callable from other directories, first change to its consumer
module directory. Follow the [CLI contract](../cli.md) for commands and exit
statuses, and the [configuration reference](../config.md) for the release profile.
For consumers using vendoring, register the CLI before running `go mod vendor`
so the vendor tree includes its dependencies. `go tool` follows Go's normal
vendor selection.
## Go API consumers ## Go API consumers
@@ -43,19 +58,54 @@ required imports and select the module version:
```sh ```sh
: "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}" : "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
go get "gitea.maximumdirect.net/eric/ReleaseKit@${RELEASEKIT_TOOL_VERSION}" go get "go.maximumdirect.net/releasekit@${RELEASEKIT_TOOL_VERSION}"
go mod tidy go mod tidy
``` ```
API consumers can also register the CLI with `go get -tool` as above. Both uses API consumers can also register the CLI with `go get -tool` as above. Both uses
share the module-selected ReleaseKit version. share the module-selected ReleaseKit version.
## Migrating from the original module path
Starting with v0.5.0, ReleaseKit's module is `go.maximumdirect.net/releasekit`.
Earlier releases declare `gitea.maximumdirect.net/eric/ReleaseKit`. The migration
is a direct cutover with the existing facade roles and command behavior.
For API consumers, update imports of the root facade and `buildinfo` to
`go.maximumdirect.net/releasekit` and
`go.maximumdirect.net/releasekit/buildinfo`, including generated code and tests.
Select v0.5.0 or a later compatible release using the package-dependency command
above. Packages imported through the old and new paths have distinct Go
identities; coordinate updates in packages that exchange ReleaseKit facade
types. Builders and applications using `buildinfo` should adopt the new path
together so version injection addresses the runtime implementation they use.
For CLI tool consumers, remove the old tool declaration before registering the
new CLI package with the tool-dependency command above:
```sh
go mod edit -droptool=gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
```
Update copied release launchers and Woodpecker workflows so their
`releasekit_module` value is `go.maximumdirect.net/releasekit`. Use the maintained
examples matching the selected tool version. Run `go mod tidy` after updating
imports and tool declarations, review any old-path dependencies retained
transitively, and commit the resulting module files with the workflow changes.
Existing consumer tags retain their recorded module files, tool selection, and
matching workflows for retries. Apply these migration steps to new consumer
revisions. Source repository and hosted release URLs use
`https://forge.maximumdirect.net/base-layer/releasekit`; the vanity path is the
Go module identity. The Go consumer minimum remains 1.26.0.
## CI adoption ## CI adoption
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows) The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
supports both tool and ordinary package dependencies. It builds ReleaseKit from supports both tool and ordinary package dependencies. It runs `go tool releasekit`
the tagged consumer's module selection, so the committed module files also with temporary module metadata derived from the tagged consumer's module
govern CI delivery and retries. Registering the CLI as a tool is sufficient for 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. consumers that use ReleaseKit only for maintainer commands.
Upgrade the module-selected tool and workflow together. For a tag created before Upgrade the module-selected tool and workflow together. For a tag created before

View File

@@ -8,7 +8,7 @@ import (
"context" "context"
"fmt" "fmt"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "go.maximumdirect.net/releasekit"
) )
func build(ctx context.Context) error { func build(ctx context.Context) error {
@@ -53,7 +53,9 @@ module. The default output directory is `<module>/dist`; a relative `OutputDir`
is resolved from the module, while an absolute path is used as supplied. is resolved from the module, while an absolute path is used as supplied.
Selected artifact filenames are regenerated. Other files in the output Selected artifact filenames are regenerated. Other files in the output
directory are left in place. A failed later build or native version check directory are left in place. A failed later build or native version check
returns the binaries already generated in `BuildResult.Artifacts`. returns completed, hashed binaries in `BuildResult.Artifacts`. If hashing a
newly built file fails, the error identifies its path and earlier completed
artifacts remain in the result.
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

View File

@@ -98,6 +98,13 @@ The `Candidate` supplies both `RepositoryDir` and `ModuleDir`, plus the declared
module-local product checks. ReleaseKit calls `AdditionalChecks` once after module-local product checks. ReleaseKit calls `AdditionalChecks` once after
shared validation when supplied. shared validation when supplied.
For local token files, set `Validation.ForbiddenFiles` to the repository-relative
paths that should stay out of the committed candidate, such as
`[]string{".releasekit/gitea-token"}`. Failures use `forbidden-files` and carry the
selected committed paths in `ValidationError.Findings`. This selection is
independent of module and shared-file inputs; see [GoDoc](../../check.go) for
the API contract and the [configuration reference](../config.md) for CLI use.
`ValidationSpec.SharedFiles` adds committed, repository-relative files to `ValidationSpec.SharedFiles` adds committed, repository-relative files to
ReleaseKit's optional file checks without enabling any checks. A nil slice ReleaseKit's optional file checks without enabling any checks. A nil slice
selects the optional defaults `COPYRIGHT`, `LICENSE`, and selects the optional defaults `COPYRIGHT`, `LICENSE`, and

View File

@@ -1,6 +1,8 @@
# Deliver a tagged source release # Deliver a tagged source release
Use `releasekit.Deliver` or the `deliver` command after a source tag exists. Use `releasekit.Deliver` or the `deliver` command after a source tag exists.
Select the delivery backend explicitly in the profile or
`DeliveryDestination`; the destination URL does not infer a backend.
Delivery status is separate from source publication: a delivery problem does Delivery status is separate from source publication: a delivery problem does
not move or remove the tag, and a retry can use the same immutable version. not move or remove the tag, and a retry can use the same immutable version.
The operation accepts historical lightweight and annotated tags, including a The operation accepts historical lightweight and annotated tags, including a
@@ -39,9 +41,15 @@ For binary profiles, call `PrepareBuildDependencies` with a
`DependencyPreparationRequest` selecting the repository and module after `DependencyPreparationRequest` selecting the repository and module after
checkout preparation and before delivery. It runs `go mod download` in that checkout preparation and before delivery. It runs `go mod download` in that
module with workspace discovery disabled and the available compatible local Go module with workspace discovery disabled and the available compatible local Go
toolchain. toolchain. Inside a Git checkout, `RepositoryDir` can name a subdirectory;
The operation may use the network and updates the Go module cache while `ModuleDir` is interpreted relative to the containing checkout root, and an
preserving the caller's proxy, private-module, credential, and cache settings. empty selection chooses that root. Previously, a supplied checkout subdirectory
acted as the selection base. Outside a checkout, the supplied directory remains
the base for `ModuleDir`.
Preparation overrides an alternate module file selected by `GOFLAGS`, while
retaining other effective Go settings. The operation may use the network and
updates the Go module cache while preserving the caller's proxy, private-module,
credential, and cache settings.
If checksums are missing, Go may update `go.sum`. For binary CI delivery, prepare 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 and commit the selected module's `go.sum` before tagging: delivery verifies the
tagged source remains clean after dependency preparation. See the tagged source remains clean after dependency preparation. See the
@@ -62,6 +70,7 @@ Add a Gitea destination to the profile:
{ {
"product": "Example", "product": "Example",
"delivery": { "delivery": {
"forge": "gitea",
"base_url": "https://gitea.example", "base_url": "https://gitea.example",
"owner": "example", "owner": "example",
"repository": "example", "repository": "example",
@@ -77,11 +86,37 @@ git checkout --detach v1.2.3
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver v1.2.3 RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver v1.2.3
``` ```
The CLI reads the named token variable only for `deliver`; its default name is The CLI resolves the token for delivery using the configured environment
`RELEASEKIT_GITEA_TOKEN`. It verifies the local checkout and hosted tag before variable or token file. It verifies the local checkout and hosted tag before
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 local delivery, save a Gitea personal access token in a git-ignored file such
as `.releasekit/gitea-token` and configure:
```json
{
"product": "Example",
"validation": {
"forbidden_files": [".releasekit/gitea-token"]
},
"delivery": {
"forge": "gitea",
"base_url": "https://gitea.example",
"owner": "example",
"repository": "example",
"token_file": ".releasekit/gitea-token"
}
}
```
Add `.releasekit/` to the consumer's ignore rules and use owner-only file
permissions for the saved token. Then run `releasekit deliver v1.2.3` from the
tagged checkout. CI can continue supplying the environment token. See the
[configuration reference](../config.md) for precedence, path resolution, and the
optional candidate check. The validator checks the selected committed paths;
it does not scan for secrets in other files or past commits.
For nested modules, the note comes from that module's committed 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. `docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
@@ -104,8 +139,9 @@ directory must contain the private `.releasekit-build.json` receipt created by
`build`. ReleaseKit checks its schema, repository, commit, version, resolved `build`. ReleaseKit checks its schema, repository, commit, version, resolved
module directory and path, full tag, normalized build settings, and artifact module directory and path, full tag, normalized build settings, and artifact
declarations. Old receipts must be regenerated with the updated `build` declarations. Old receipts must be regenerated with the updated `build`
command. Delivery verifies the artifact bytes and sizes before making a hosted command. Delivery verifies the artifact bytes and sizes after read-only
request; caller-owned files remain available for retries. preflight and before any hosted write; caller-owned files remain available for
retries.
Ordinary `deliver` does not download application dependencies or change the Ordinary `deliver` does not download application dependencies or change the
checkout. checkout.
@@ -118,6 +154,10 @@ an upload was confirmed during this invocation; `Pending` means known missing
work; `Conflicting` means different hosted content was found; `Uncertain` means work; `Conflicting` means different hosted content was found; `Uncertain` means
the remote state could not be resolved. the remote state could not be resolved.
An error can accompany `complete` when hosted delivery succeeded but removal
of managed build files failed. Address the reported local cleanup failure;
the completed hosted delivery does not need to be repeated.
Fix local or preflight failures before retrying. For partial delivery, fix the Fix local or preflight failures before retrying. For partial delivery, fix the
reported cause and retry the same version. Resolve an unknown remote outcome reported cause and retry the same version. Resolve an unknown remote outcome
before retrying. Investigate conflicts and use a new version if the source or before retrying. Investigate conflicts and use a new version if the source or
@@ -125,24 +165,74 @@ published content needs correction. If rebuilding could change bytes already
on the server, retain the original build output and retry with `--assets DIR`. on the server, retain the original build output and retry with `--assets DIR`.
The command does not run candidate checks or publish tags. The command does not run candidate checks or publish tags.
## Credential preflight
Both `Deliver` and the CLI perform a read-only destination preflight after
cheap local request and source checks. It happens before managed builds and
full artifact size and digest verification. If it succeeds, delivery verifies
the local artifact contents before making any hosted write. A failed preflight
leaves delivery `not_attempted`, including when the service is unavailable.
Call `PreflightDelivery` directly when another caller-controlled operation
should follow credential verification. The check needs no checkout or published
tag and performs no test release or upload. For Gitea it verifies authenticated
repository access; exposed metadata can identify known deficiencies, but
successful preflight does not guarantee later release or asset writes. A
`DeliveryPreflightError` identifies invalid destination settings, missing or
rejected credentials, known insufficient permissions, an inaccessible
destination, or unavailable verification. Its `Unwrap` method preserves the
cause for `errors.Is` and
`errors.As`. A 403 means access was denied, not necessarily that a token
expired; a 404 can mean a missing repository or hidden access.
```go
err := releasekit.PreflightDelivery(ctx, releasekit.DeliveryDestination{
Forge: releasekit.ForgeGitea,
BaseURL: "https://gitea.example",
Owner: "example",
Repository: "example",
Token: token,
})
if err != nil {
var preflight *releasekit.DeliveryPreflightError
if errors.As(err, &preflight) {
log.Printf("delivery preflight %s: %v", preflight.Kind, preflight.Err)
}
return err
}
```
The CLI performs the same check automatically. For binary `ci-deliver`, it
checks access before application dependency downloads, then `Deliver` checks
again after preparation. Page-only CI needs no separate preflight call.
`check`, `build`, and source `publish` remain independent of hosted delivery
credentials. Calling preflight before source publication is optional and does
not add a publication gate.
## Go API ## Go API
Pass the Gitea credential explicitly. Set `Build` to a `BuildSpec` to compile Pass the Gitea credential explicitly and select `ForgeGitea`. Set `Build` to a
selected targets as part of delivery. The facade verifies the local tag, clean `BuildSpec` to compile selected targets as part of delivery. The facade verifies
checkout, and committed release note before compiling. Managed output is stored the local tag, clean checkout, and committed release note before compiling.
outside the checkout and removed when delivery returns. `Output` receives build Managed output is stored outside the checkout and removed when delivery
progress. With no build, or with an empty target set, delivery is page-only. returns. `Output` receives build 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 its repository, module directory, declared module path, full ReleaseKit checks its repository, module directory, declared module path, full
tag, semantic version, tagged commit, normalized settings, and artifact evidence tag, semantic version, tagged commit, normalized settings, and artifact evidence
against the selected committed module. Binary results must record verified against the selected committed module. Binary results must record verified
source. The current files are checked against their recorded digests before any source. After cheap local and source checks, delivery performs read-only
hosted request. The files remain caller-owned, so callers can retain the credential preflight, verifies the local artifacts in full, and then makes
original bytes for retries and are responsible for cleanup. With neither hosted writes. The current files are checked against their recorded digests
artifact source, an empty managed target set, or a matching empty prebuilt before those writes. Preflight confirms only the access evidence available from
result, delivery is page-only. Do not set both `Build` and `Prebuilt` in one the service, as described above. The files remain caller-owned, so callers can
request. retain the original bytes for retries and are responsible for cleanup. With
neither artifact source, an empty managed target set, or a matching empty
prebuilt result, delivery is page-only. A prebuilt result with no targets or artifacts
must still identify the matching repository, module directory and path, tag,
commit, and version; a zero `BuildResult` does not. 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{
@@ -151,7 +241,8 @@ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
Product: "Example", Product: "Example",
Version: "v1.2.3", Version: "v1.2.3",
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
Destination: releasekit.GiteaDestination{ Destination: releasekit.DeliveryDestination{
Forge: releasekit.ForgeGitea,
BaseURL: "https://gitea.example", BaseURL: "https://gitea.example",
Owner: "example", Owner: "example",
Repository: "example", Repository: "example",
@@ -196,15 +287,19 @@ a `delivery` profile. Page-only and binary behavior both use this template; the
profile's build target list selects whether application binaries are built and profile's build target list selects whether application binaries are built and
delivered. delivered.
Before running Go, the template fetches the explicit event tag, compares its Before running Go, the template fetches the explicit event tag and independently
peeled commit with the provider's full commit ID without regard to hex case, resolves both the tag and `CI_COMMIT_SHA` to source commits. The event ID may
checks out that commit detached, and confirms the source tree is clean. It then identify an annotated tag object or a commit. The template requires the resolved
copies the tagged `go.mod` and any `go.sum` to a temporary directory outside commits to match, checks out that commit detached, and confirms the source tree
the checkout. `go list -m` confirms that the module selects ReleaseKit; Go uses is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
an alternate `-modfile` to prepare dependencies and build the selected directory outside the checkout. `go list -m` confirms that the module selects
`cmd/releasekit` package without changing the tagged module files. The template ReleaseKit. The template adds its CLI tool declaration to that temporary copy
invokes that executable with `CI_WORKSPACE`, `CI_COMMIT_SHA`, and and prepares tool dependencies with `go list -deps`. It then uses
`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable. `go tool -modfile=... releasekit` to build, cache, and run the selected tool with
`CI_WORKSPACE`, the resolved event commit, and `CI_COMMIT_TAG`. The tagged module
files stay unchanged, including for consumers with an ordinary package
dependency. A cleanup trap removes the temporary metadata; Go manages the
cached executable. The command preserves the tool's exit status.
Tool bootstrap and application artifact preparation are separate. The bootstrap Tool bootstrap and application artifact preparation are separate. The bootstrap
resolves and builds ReleaseKit from the tagged consumer's module configuration. resolves and builds ReleaseKit from the tagged consumer's module configuration.
@@ -242,11 +337,13 @@ 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 separately installed 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 go.maximumdirect.net/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"
``` ```
The module-based workflow follows the ReleaseKit version selected by the tagged The module-based workflow follows the ReleaseKit version selected by the tagged
@@ -255,6 +352,12 @@ consumer module configuration when adopting a newer ReleaseKit version and
verify it with a tag fixture. The command and profile behavior are documented 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
@@ -276,7 +379,9 @@ describes these checks.
To isolate a public ReleaseKit download from application settings and the To isolate a public ReleaseKit download from application settings and the
existing module cache, run the following with a compatible local Go toolchain. 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 Replace `v0.5.0` with the affected vanity-path version. For an earlier release,
use its original `gitea.maximumdirect.net/eric/ReleaseKit` module path instead.
This diagnostic accesses the public
Go proxy and checksum database and retains its temporary directory for inspection. Go proxy and checksum database and retains its temporary directory for inspection.
```sh ```sh
@@ -288,7 +393,7 @@ Go proxy and checksum database and retains its temporary directory for inspectio
GOPRIVATE= GONOPROXY= GONOSUMDB= \ GOPRIVATE= GONOPROXY= GONOSUMDB= \
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \ GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
GOMODCACHE="$checksum_dir/modules" \ GOMODCACHE="$checksum_dir/modules" \
go mod download -json gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0 go mod download -json go.maximumdirect.net/releasekit@v0.5.0
) )
``` ```

View File

@@ -114,9 +114,81 @@ candidate and remote guards, creates an annotated tag for the captured commit,
and pushes only that tag object. and pushes only that tag object.
A successful result means Git confirmed the push or an exact authoritative tag A successful result means Git confirmed the push or an exact authoritative tag
query confirmed it after a push error. The local tag is retained after a failed query confirmed it after a push error. A failed inspection after local tag
push. `PublicationState` distinguishes a known failure, conflict, confirmed creation can leave the tag ref even when its object ID is unavailable; when
publication, and unresolved outcome. Resolve an unknown result against the the ID was established it remains in `TagObject`. The local tag is retained
captured endpoint before retrying; see the [maintainer procedure](../release.md) after a failed push. `PublicationState` distinguishes a known failure, conflict,
for the recovery steps. The [CLI contract](../cli.md) documents command output confirmed publication, and unresolved outcome. Resolve an unknown result
and exit statuses. against the captured endpoint before retrying; see the [maintainer procedure](../release.md)
for the recovery steps. The result's endpoint is the exact URL and can contain
credentials; handle it accordingly. The [CLI contract](../cli.md) documents
command output and exit statuses.
## Retain and reconcile publication evidence
`Publish` automatically attempts to save one private record for each invocation.
The default store is under the repository's Git common directory, so linked
worktrees share it. `ReleaseRequest.PublicationRecordDir` can override the
directory; relative overrides resolve from the source repository root. The CLI
equivalent is `publish --record-dir DIR`. ReleaseKit creates its owned storage
directories privately and creates record files with owner-only permissions;
existing directory permissions are left as they are. The caller owns storage
retention and removal.
The CLI prepares a recovery entry point when a failed publication has a saved
record. Registered-tool consumers use their selected module's Go tool directly.
Command preparation uses a separate bounded context after publication failure,
including cancellation. A module-preparation warning can accompany a persistent
native executable fallback; the saved record and publication result retain their
original meaning.
For API-only consumers, ReleaseKit retains private bootstrap module and checksum files
under `<Git common directory>/releasekit/publication-recovery/`, with private
directory and file permissions. These files add the CLI tool declaration and
prepare its module graph without changing consumer source. The bootstrap has
its own module root, so tool resolution is independent of consumer vendoring;
local replacements retain their original source locations. These files survive
a launcher's temporary module-file cleanup. Registered-tool commands run from
the selected module; API-only commands run from the private bootstrap root.
Retain the source checkout and private recovery files while the entry point is
needed. Remove the private recovery directories when
their recovery value expires. Another supported CLI can read the saved record
independently of these launch files.
The saved endpoint is retained exactly for later observation and may include
credentials. Treat records, `PublicationEvidence`, and `PublicationResult` as
sensitive. Store overrides in a location with suitable access controls, avoid
printing raw endpoint values, and remove records when their recovery value has
expired. Diagnostic errors and CLI output apply credential redaction; the
private record and typed evidence preserve the raw endpoint.
Recording failure is reported in `PublicationResult.RecordError`, separately
from `Publish`'s return error and publication state. A successful push remains
successful if storage fails, but the saved handoff is unavailable. Callers can
still pass `result.Evidence` directly to `ReconcilePublication` when it contains
the required identity. For example:
```go
func observe(ctx context.Context, result releasekit.PublicationResult) (releasekit.PublicationReconciliationResult, error) {
return releasekit.ReconcilePublication(ctx, result.Evidence)
}
```
The evidence is populated as publication identity becomes available, including
partial evidence on some earlier failures. An abrupt process termination before
the save point can leave no record; removed storage also removes the handoff.
Records from older attempts may lack the tag object, endpoint, or other identity
needed for a conclusive observation.
Use `LoadPublicationEvidence(path)` to read one explicit record,
`ListPublicationAttempts(ctx, request)` to discover readable records and
per-record diagnostics in a local directory, or `ReconcilePublication(ctx,
evidence)` to observe a selected endpoint. Listing does not query remotes, and
neither listing nor reconciliation changes saved records. Recovery does not
infer the attempt from current HEAD, local tags, module files, or profiles.
`ReconcilePublication` returns a nil error for a completed `published`,
`not_published`, or `conflict` observation; unresolved `unknown` results include
a typed error that retains the underlying cause. See the
[GoDoc](../../recovery.go) for the exported fields and error kinds, the
[CLI guide](../cli.md) for command statuses, and the
[record format guide](../integrations/publication-records.md) for supported
stored and structured formats.

View File

@@ -4,7 +4,7 @@ Applications can read their embedded version through the public `buildinfo`
package: package:
```go ```go
import "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" import "go.maximumdirect.net/releasekit/buildinfo"
version := buildinfo.Version() version := buildinfo.Version()
``` ```

View File

@@ -1,9 +1,10 @@
# Gitea release pages # Gitea release pages
ReleaseKit's public `Deliver` operation and `deliver` command verify tagged ReleaseKit's public `Deliver` operation and `deliver` command select Gitea
source, find or create the selected module's release page, inspect existing assets, and explicitly, verify tagged source, find or create the selected module's release
upload missing assets through the internal Gitea client. Page-only and page, inspect existing assets, and upload missing assets. Shared delivery code
binary-producing profiles use the same delivery path. The owns content comparison and recovery; the Gitea adapter owns the HTTP protocol.
Page-only and 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;
this guide describes the Gitea protocol and its failure behavior. this guide describes the Gitea protocol and its failure behavior.
@@ -13,6 +14,23 @@ 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.
Before a build or full local artifact verification, Gitea preflight makes two
authenticated, read-only repository requests: `GET /api/v1/repos/{owner}/{repo}`
resolves the repository, then `GET /api/v1/repositories/{id}` confirms access
through the authenticated repository route. The response identity must match
the configured owner and repository. This check also works without a checkout
or a published tag and makes no release or asset writes.
A 401 response identifies a rejected credential. A 403 identifies denied
access but does not establish that the credential is invalid. A 404 can mean a
missing repository or access hidden by the service. Network failures, service
errors, and malformed responses that prevent verification are reported as
unavailable. Repository `permissions.push` describes Git code-push access; it
does not establish permission to create release pages or upload assets. Gitea
does not expose enough evidence through this probe to verify those write scopes,
so they remain unverified and later writes remain authoritative. Preflight does
not request unrelated user scopes or create a test release or asset.
The selected module derives a complete tag from its semantic version, declared The selected module derives a complete tag from its semantic version, declared
module path, and physical repository-relative directory. For example, version module path, and physical repository-relative directory. For example, version
`v1.2.3` in `integrations/ckconfig` uses `v1.2.3` in `integrations/ckconfig` uses
@@ -27,34 +45,40 @@ The default shared validation convention uses repository-root `COPYRIGHT`,
layout and release-note links. Use the complete derived tag as the immutable 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. source ref and the actual repository-relative file paths in those links.
Shared-file selection controls validation inputs; delivery preserves the Shared-file selection controls validation inputs; delivery preserves the
committed note and its footer links. The client first reads committed note and its footer links. After successful preflight, the Gitea
adapter 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 is accepted by shared delivery policy only when it has 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. Slash-containing tags are escaped as one URL path segment. conflicting page. Slash-containing tags are escaped as one URL path segment.
Page-only delivery does not require a release ID in this lookup response. Asset
operations require a positive release ID; a missing or nonpositive ID is
reported as a malformed service observation before an asset request is made.
If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases` If the page is absent, the adapter posts to
with the selected full tag and commit, the release note's H1 as title, the complete `/api/v1/repos/{owner}/{repo}/releases` with the selected full tag and commit,
note as body, and draft and prerelease disabled. A create conflict, interrupted the release note's H1 as title, the complete note as body, and draft and
response, or malformed create response (including inconsistent decoded fields) prerelease disabled. A create conflict, interrupted response, or malformed
triggers one lookup. An exact page found create response (including inconsistent decoded fields) triggers one lookup by
there confirms the existing content; a differing page is a conflict. If that the shared workflow. An exact page found there confirms the existing content;
lookup cannot determine the result after a possibly successful write, the a differing page is a conflict. If that lookup cannot determine the result
client returns an unknown outcome. Other HTTP failures retain their structured after a possibly successful write, delivery returns an unknown outcome. Other
failure kind and status code. A recovered create retains its write history; HTTP failures retain their structured failure kind and status code. A recovered
a later asset-inspection failure reports partial page progress. create retains its write history; a later asset-inspection failure reports
partial page progress.
For a binary-producing release, the client can build a deterministic For a binary-producing release, shared delivery builds a deterministic
`SHA256SUMS` file from the declared artifact names and SHA-256 digests. Entries `SHA256SUMS` file from the declared artifact names and SHA-256 digests. Entries
are sorted by filename and use the standard two-space separator. A source-only are sorted by filename and use the standard two-space separator. A source-only
release produces no manifest. release produces no manifest.
Asset inspection lists the release attachments, streams each declared existing The adapter lists release attachments and opens their download streams. Shared
file, and compares its SHA-256 digest. It reports each asset as matching, delivery compares each declared existing file by SHA-256 digest and reports it
missing, conflicting, or unverifiable. Same-name attachments with different as matching, missing, conflicting, or unverifiable. Same-name attachments with
bytes are conflicts even when their reported size matches. An existing different bytes are conflicts even when their reported size matches. An existing
`SHA256SUMS` must exactly match the complete expected manifest; a subset or `SHA256SUMS` must exactly match the complete expected manifest; a subset or
different manifest is a conflict. An unreadable attachment is unverifiable, different manifest is a conflict. An unreadable attachment is unverifiable,
not missing. A failed read-only inspection reports `not_attempted`; if this not missing. A failed read-only inspection reports `not_attempted`; if this
@@ -63,12 +87,15 @@ Unknown-write outcomes are reserved for unresolved writes. Unrelated attachments
Attachment downloads and redirects carry the Gitea authorization token only Attachment downloads and redirects carry the Gitea authorization token only
when their scheme, host, and port match the configured service. Cross-origin when their scheme, host, and port match the configured service. Cross-origin
content is downloaded without that token and verified by its digest. content is downloaded without that token and verified by its digest. Downloads
follow at most ten redirects; an additional redirect is unverifiable.
Comparison reads no more than the declared local size plus one byte; shorter
or longer content conflicts, regardless of the server's reported size.
The upload operation accepts only an inspection result without conflicts or Shared delivery uploads only after inspection finds no conflicts or
unverifiable assets. It uploads missing binaries in filename order and then the unverifiable assets. It uploads missing binaries in filename order and then the
missing `SHA256SUMS`, using multipart field `attachment` and the filename query missing `SHA256SUMS`, using the adapter's multipart field `attachment` and
parameter `name`. Matching attachments are retained; unrelated and existing filename query parameter `name`. Matching attachments are retained; unrelated and existing
attachments are never overwritten or deleted. Results use disjoint current attachments are never overwritten or deleted. Results use disjoint current
observations: matching, uploaded, pending, conflicting, and uncertain. An asset observations: matching, uploaded, pending, conflicting, and uncertain. An asset
moved from known missing work to another category is removed from `pending`; moved from known missing work to another category is removed from `pending`;

View File

@@ -0,0 +1,123 @@
# Publication records and recovery output
This guide describes the supported private publication-record envelope and the
versioned JSON documents emitted by the recovery CLI. The
[publication consumer guide](../consumers/publish.md) owns storage placement,
permissions, retention, and sensitive-data handling. The
[CLI reference](../cli.md) owns command behavior, path selection, and exit
statuses; the exported Go contract is documented in [GoDoc](../../recovery.go).
## Private attempt record
Each saved attempt is one complete JSON document with this versioned envelope:
```json
{
"format_version": 1,
"evidence": {
"repository_dir": "/work/project",
"module_dir": "/work/project",
"module_path": "example.com/project",
"version": "v1.2.3",
"tag": "v1.2.3",
"candidate_commit": "<full Git object ID>",
"tag_object": "<full Git object ID>",
"endpoint": "ssh://git.example/owner/project.git",
"remote": "origin",
"recorded_at": "2026-11-02T12:00:00Z"
}
}
```
The format version is an integer. Evidence fields may be absent when publication
stopped before they were captured; loading preserves that partial evidence and
does not fill fields from the current checkout. Additional fields in a
supported version are ignored. A missing or unsupported version, malformed or
multiple JSON documents, or fields with invalid structural types are reported
as record-format errors. The codec supports versions 1 and 2; no migration from
an unsupported version is performed.
Ordinary UTF-8 evidence uses version 1. Evidence containing other pathname or
endpoint bytes uses version 2 with the same `evidence` object and an additional
`evidence_bytes` object. Each entry in `evidence_bytes` uses an evidence field
name and a standard base64 string containing its exact bytes. That entry
supplies the field's value when loading; the writer omits its lossy string form
from `evidence`. Invalid known-field types or base64 encodings produce a
record-format error with the available decoding cause. Unknown field names are
ignored. This representation applies
to every evidence field and preserves the same identity as typed Go evidence.
Older CLIs that support only version 1 report an unsupported version for these
byte-containing records; use a CLI supporting version 2 to load them.
The `endpoint` value is stored exactly as captured and may contain credentials.
Private records are sensitive. Do not copy them into public logs or source
control. For their location, permissions, and lifecycle, see the
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence).
## Recovery CLI JSON
After valid command arguments, `--json` emits one JSON document to standard
output for both successful and failed recovery operations. Each document has
`schema_version: 1` and a `command` name. These are CLI presentation documents,
not private attempt records or a Go serialization of public structs.
`reconcile` includes `record_path`, safe `evidence`, and `state` when a record
was read. Available `observed_tag_object` and `observed_commit` fields are
included when present. A `diagnostic` is included for unresolved observation
or record loading failure. Loading failure omits evidence, state, and observed
IDs.
`publication-attempts` includes `record_dir`, an `attempts` array with each
`record_path` and safe `evidence`, and a `diagnostics` array. A top-level
`diagnostic` describes enumeration or cancellation failure. Both arrays are
present when empty so consumers can decode the same shape for empty stores.
For example, an unresolved reconciliation can have this shape:
```json
{
"schema_version": 1,
"command": "reconcile",
"record_path": "/private/attempt-abc.json",
"evidence": {
"repository_dir": "/work/project",
"module_path": "example.com/project",
"version": "v1.2.3",
"tag": "v1.2.3",
"candidate_commit": "<full Git object ID>",
"tag_object": "<full Git object ID>",
"endpoint": "ssh://git.example/owner/project.git",
"remote": "origin"
},
"state": "unknown",
"diagnostic": {
"kind": "remote_observation",
"message": "<credential-safe diagnostic>"
}
}
```
A discovery document always contains both arrays, including for an empty store:
```json
{
"schema_version": 1,
"command": "publication-attempts",
"record_dir": "/private/publication-attempts",
"attempts": [],
"diagnostics": []
}
```
CLI evidence uses the private record's snake-case field names. Endpoint strings
in CLI output are credential-redacted; the private record and typed
`PublicationEvidence` retain the exact endpoint. A diagnostic contains `kind`
and safe `message`, with `record_path` and `missing_fields` when applicable.
Raw Go errors and private record contents are not emitted as JSON fields.
Consumers should select attempts by their returned record paths and evidence.
The listing order is deterministic by path, not a recommendation to choose the
first or newest attempt. Unknown fields may be added in later CLI schema
versions; reject unsupported `schema_version` values rather than interpreting
them as version 1. See the [CLI reference](../cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts)
for invocation and status behavior.

View File

@@ -8,21 +8,37 @@ 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.
For CLI publication recovery, the package selects a registered module tool or
prepares a durable private module root for an API-only consumer. Go's module
graph query includes transitive dependencies; preparation retains module
requirements, versioned replacements, and checksums. Local replacements resolve
against their original module directory before relocation. The original main
module remains a local replacement, including when releasing ReleaseKit itself.
The private root provides tool resolution independently of consumer vendoring.
The CLI formats the later invocation using a separate bounded context after
publication failure. It falls back to a persistent native executable when module
selection is unavailable or preparation fails, and excludes recognized temporary
Go-work executables from that fallback. The
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence)
owns recovery-file placement and retention.
## Command output ownership ## Command output ownership
`Tool.Run` executes a status-only command. It forwards combined stdout and `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 stderr to the optional writer while the child runs, retaining only the last
on success. Failures include the captured diagnostic text and preserve the 1,048,576 bytes for failure diagnostics. A truncation notice identifies omitted
underlying process or forwarding error. earlier bytes. Successful calls release their capture; failures preserve the
underlying process or forwarding error. Dependency preparation uses the same
bounded diagnostic capture.
`Tool.Output` returns combined stdout and stderr for commands whose output is `Tool.Output` returns combined stdout and stderr for commands whose output is
needed by the caller, such as compiler identification. After the child stops, 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 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 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; 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 the component benchmark compares full and bounded capture for modest output,
32 KiB writes, forwarding to `io.Discard`. Smaller writes incur buffer-growth one 8 MiB write, and 8 MiB in 32 KiB writes, forwarding to `io.Discard`.
allocations. The [output benchmark](../../internal/gotool/performance_test.go) The [output benchmark](../../internal/gotool/performance_test.go)
measures total allocated bytes per operation, excluding subprocess overhead and measures total allocated bytes per operation, excluding subprocess overhead and
diagnostic strings. These totals count all allocations, including superseded diagnostic strings. These totals count all allocations, including superseded
buffers. The returned slice retains the final buffer holding the complete output. buffers. The returned slice retains the final buffer holding the complete output.
@@ -33,8 +49,10 @@ coexist. Disk-backed diagnostics could reduce large failure peaks, but add file
lifecycle and cleanup work and remain a follow-up option. lifecycle and cleanup work and remain a follow-up option.
Dependency preparation uses a separate command environment so proxy, private Dependency preparation uses a separate command environment so proxy, private
module, checksum, and cache settings remain available. Its completed capture module, checksum, cache, and other effective settings remain available. Its
uses the same ownership transfer when formatting a failure. `go mod download -modfile=` command overrides an alternate module file selected
through `GOFLAGS`, preserving ordinary module discovery. Its bounded capture
is formatted into the error on failure.
## Configuration discovery ## Configuration discovery

View File

@@ -11,12 +11,14 @@ two Go facades and a command-line interface:
- `buildinfo` is the small runtime facade. `Version` reports the injected - `buildinfo` is the small runtime facade. `Version` reports the injected
application version, the main-module version when available, or `dev`. application version, the main-module version when available, or `dev`.
- `cmd/releasekit` is the executable interface. Its `version`, `check`, - `cmd/releasekit` is the executable interface. Its `version`, `check`,
`publish`, `build`, `deliver`, and `ci-deliver` commands adapt profiles and `publish`, `reconcile`, `publication-attempts`, `build`, `deliver`, and
command output to the supported library operations. `ci-deliver` commands adapt profiles and command output to the supported
library operations.
See the [CLI contract](../cli.md), [configuration reference](../config.md), See the [CLI contract](../cli.md), [configuration reference](../config.md),
[candidate check guide](../consumers/check.md), [candidate check guide](../consumers/check.md),
[source publication guide](../consumers/publish.md), [source publication guide](../consumers/publish.md),
[publication record format](../integrations/publication-records.md),
[build guide](../consumers/build.md), [build guide](../consumers/build.md),
[delivery guide](../consumers/delivery.md), and [delivery guide](../consumers/delivery.md), and
[runtime version guide](../consumers/runtime-version.md) for task-oriented use. [runtime version guide](../consumers/runtime-version.md) for task-oriented use.
@@ -24,7 +26,7 @@ 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 `go.maximumdirect.net/releasekit`, with Go language
version `1.26.0`. `go.mod` requires `golang.org/x/mod` and Goldmark for internal version `1.26.0`. `go.mod` requires `golang.org/x/mod` and Goldmark for internal
validation; public facades do not expose either dependency. `go list ./...` validation; public facades do not expose either dependency. `go list ./...`
reports these implemented packages: reports these implemented packages:
@@ -39,16 +41,17 @@ reports these implemented packages:
| `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` | Resolved-identity builds, normalized settings, selected-module compilation, version injection, external native smoke output, 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` | 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/delivery` | Read-only backend preflight, captured-commit module identity and note selection, literal-tag checkout checks, prebuilt identity binding, managed builds in disposable storage outside the checkout, artifact declaration, manifest assembly, shared page and asset policy, streamed comparisons, write recovery, and delivery outcomes. |
| `internal/forge` | Backend contract, neutral observations and failures, opaque handles, preflight classifications, and write evidence shared by delivery policy and adapters. |
| `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/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, full-tag hosted release pages, asset inspection, manifests, and uploads. | | `internal/gitea` | Gitea destination rules, authenticated repository preflight, HTTP operations and codecs, opaque handle interpretation, response validation, redirects, multipart streaming, and protocol-to-neutral failure conversion. |
| `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/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, including owned stdin/stdout pipes for serialized exchanges, 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/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/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, pre-push evidence recording, saved-attempt discovery, later observation, and recovery state. |
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for 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. |
@@ -74,11 +77,28 @@ it does not consult neighboring files or traverse submodule checkouts.
See the [shared validation guide](validation.md) for checker, Go command, and See the [shared validation guide](validation.md) for checker, Go command, and
candidate-file integration mechanics. candidate-file integration mechanics.
Delivery verifies all local binary sizes and digests before hosted requests. Publication records and recovery follow a separate path from candidate
Its Gitea upload path reuses that verification and streams missing files once validation. `internal/publish` captures and saves attempt evidence before the
for transport. Independent calls to the internal client's `UploadMissing` tag push, lists local records, and observes explicitly selected evidence through
verify missing local files before writing. Both paths share remote inspection, `internal/git`; the root facade owns the reusable Go evidence and result types.
conflict handling, and reconciliation. The CLI's `reconcile` and `publication-attempts` commands load, present, and
select records without loading a profile or choosing an attempt automatically.
These operations do not rerun candidate checks or hosted delivery and do not
change local or remote state. The [publication consumer guide](../consumers/publish.md)
owns storage and API use; the [record format guide](../integrations/publication-records.md)
owns the private and CLI JSON formats; the [CLI contract](../cli.md) owns command
semantics and statuses.
The root facade selects the backend explicitly and passes a configured
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
request and source checks, then read-only backend preflight before managed
builds or full local artifact verification. Artifact size and digest
verification completes before hosted writes and is reused for upload streams.
Shared delivery code owns release-page matching and creation recovery, remote
asset comparison, upload sequencing, and partial or uncertain outcomes.
`internal/gitea` implements only the Gitea protocol operations behind the
neutral contract. `internal/artifact` and `internal/forge` do not depend on the
adapter.
## Capability owners and behavioral evidence ## Capability owners and behavioral evidence
@@ -88,19 +108,21 @@ 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, 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` | | Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithGoValidationSettings`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
| 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` | | Committed-file validation: formatting, workspace files, local replacements, and file size | `internal/validation`, `internal/candidate`, `releasekit.Check`, `releasekit.Publish`, CLI profile conversion | `TestCheckGoFormatUsesCapturedRegularGoBlobs`, `TestCheckWorkspaceFilesReportsCommittedBlobNames`, `TestCheckLocalReplacementsParsesCommittedModuleDeclarations`, `TestCheckLocalReplacementsFollowsCommittedModuleSymlinks`, `TestCheckFileSizeUsesStrictLimitsAndDefault`, `TestCheckFileSizeAppliesLiteralPathExclusions`, `TestCheckFileSizeUsesCommittedBlobSizes`, `TestCheckFileSizeUsesOnlyCandidateFilesIndividually` |
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` | | 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`, `TestMarkdownDocumentsParseIdenticalAndDistinctBlobs`, `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` | | 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` | | Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` |
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` | | 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` | | 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` |
| Saved attempt recording, discovery, explicit later reconciliation, and read-only recovery | `releasekit`, `internal/publish`, `internal/git`, `cmd/releasekit` | `TestLostPublicationOutputCanBeRecoveredFromExplicitRecords`, `TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts`, `TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause`, `TestRecoveryFacadeErrorUnwrapsCancellation`, `TestListPublicationAttemptsReadsExplicitExternalStore` |
| 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` | | 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`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites`, `TestEnsureReleaseEscapesSlashContainingTag` | | Explicit destination selection, standalone and automatic preflight, and delivery ordering | `releasekit`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliveryForgeSelectionIsExplicit`, `TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout`, `TestPreflightDeliveryPreservesPublicFailureKindsAndCauses`, `TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing`, `TestDeliverUnavailablePreflightStopsBeforeManagedBuild`, `TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation`, `TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne` |
| 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` |
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesUsesSelectedModuleDirectory`, `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` | | 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` | | Shared page, manifest, asset comparison, and additive retry policy | `internal/delivery` | `TestCoordinateReleaseCreatesWithFullNoteAndVerifiesRequestedTitle`, `TestCoordinateReleaseReconcilesCreateOutcomes`, `TestInspectRemoteAssetsComparesContentAndPreservesUnrelatedAssets`, `TestUploadMissingAssetsReconcilesOnceAndKeepsLaterAssetsPending` |
| 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` | | 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

View File

@@ -19,11 +19,12 @@ artifact or executing a target binary. Test, vet, and package-compilation
failures retain their `go-test`, `go-vet`, and `package-compile` identities, failures retain their `go-test`, `go-vet`, and `package-compile` identities,
selected target where applicable, and underlying causes. Its selected target where applicable, and underlying causes. Its
request carries the captured repository directory and commit, output writer, request carries the captured repository directory and commit, output writer,
and internal settings. When selected, the Go-format check loads the candidate and internal settings. The Go commands run before the committed-tree sequence,
tree once, reads regular Go blobs from the captured commit, and compares them which owns its reader's lifetime and close-error attribution. When selected,
with go/format output. It uses the selected inputs, including vendor and explicit the Go-format check loads the candidate tree once, reads regular Go blobs from
shared files, and skips the captured commit, and compares them with go/format output. It uses the
symlink blobs, reports all unformatted paths, and stops with a contextual cause selected inputs, including vendor and explicit shared files, 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. on parse or inspection errors. It runs before product checks and build callbacks.
`Request.ReadCandidateTree` lets selected file checks request candidate `Request.ReadCandidateTree` lets selected file checks request candidate
contents; the Go-only path does not enumerate the tree. contents; the Go-only path does not enumerate the tree.
@@ -59,6 +60,10 @@ workspace-file check using the same candidate inventory. Resolved module bodies
are parsed once per blob object ID; only owned filesystem-replacement targets are parsed once per blob object ID; only owned filesystem-replacement targets
and line numbers remain cached. Each declaration path receives findings from and line numbers remain cached. Each declaration path receives findings from
that summary in its own path context. that summary in its own path context.
The optional forbidden-file check uses the captured tree's full path lookup
before the other file checks. It normalizes the explicitly selected
repository-relative names and reports matching entries through `forbidden-files`
without loading their blobs. Candidate-tree cleanup covers this failure path.
The optional file-size check applies normalized literal file/directory The optional file-size check applies normalized literal file/directory
exclusions with path-boundary matching, then requests local Git sizes for the exclusions with path-boundary matching, then requests local Git sizes for the
remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
@@ -105,9 +110,11 @@ observations remain out of scope under either setting. Observations go to the
provided writer; a nil writer keeps library use quiet. Public selection is provided writer; a nil writer keeps library use quiet. Public selection is
defined in the [configuration reference](../config.md) and GoDoc. defined in the [configuration reference](../config.md) and GoDoc.
Committed-tree metadata and blob reads disable lazy fetching and Git transports ReleaseKit-owned Git commands inspect original objects even when replacement
for those child commands. Missing local objects produce inspection errors; refs exist. Committed-tree metadata and blob reads also disable lazy fetching
source preparation retains its separate network-capable operations. See Git's 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. [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. Candidate and delivery release-note reads use the same local object boundary.

View File

@@ -77,6 +77,17 @@ and orchestration bookkeeping belong behind that boundary. Additional public
packages and extension interfaces require a deliberate decision grounded in a packages and extension interfaces require a deliberate decision grounded in a
durable consumer need. durable consumer need.
Hosted delivery follows a neutral internal contract. The root facade selects a
backend explicitly and constructs it; `internal/forge` defines the backend
operations and neutral observations, failures, and write evidence;
`internal/delivery` owns shared page and asset policy; and `internal/gitea`
adapts the Gitea protocol. `internal/delivery`, `internal/forge`, and
`internal/artifact` must not depend on the Gitea adapter. Artifact declaration
and local content verification remain independent of delivery policy and forge
protocols. The compiled dependency check protects this transitive boundary.
See [ADR 0006](../adr/0006-neutral-forge-delivery-and-preflight.md) for the
selected delivery and preflight design.
Go declarations and GoDoc own exact API contracts. Compiled-export checks, Go declarations and GoDoc own exact API contracts. Compiled-export checks,
dependency checks, and external-package behavioral tests protect the selected dependency checks, and external-package behavioral tests protect the selected
facade boundaries. facade boundaries.

View File

@@ -1,18 +1,30 @@
# ReleaseKit release procedure # ReleaseKit release procedure
ReleaseKit's deliverables are its source tag and a hosted Gitea page containing ReleaseKit's deliverables are its source tag and a hosted Gitea page containing
the complete checked-in release note. The page is delivered asynchronously by the complete checked-in release note. Deliver the page locally after publication
the prepared [Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml); or asynchronously through the prepared
it is not a source-release completion check and carries no binary assets. The [Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml).
authoritative repository is `origin` at Hosted-page delivery has a separate status from source-release completion and
`https://gitea.maximumdirect.net/eric/ReleaseKit.git`, and the release branch carries no binary assets. The authoritative repository is `origin` at
is `main`. A release completes when the intended annotated tag is successfully `ssh://git@forge.maximumdirect.net:22222/base-layer/releasekit.git`, and the release
branch is `main`. The hosted Gitea destination is
`https://forge.maximumdirect.net/base-layer/releasekit`. A release completes
when the intended annotated tag is successfully
pushed, or when one exact-object recovery query confirms that the push pushed, or when one exact-object recovery query confirms that the push
succeeded. CI and consumer installation are not completion checks. succeeded. CI and consumer installation are not completion checks.
## Prepare a candidate ## Prepare a candidate
Use a clean clone on `main`. The chosen build toolchain is Go 1.26.8. Make that For local hosted-page delivery, save a Gitea personal access token in
`.releasekit/gitea-token` with owner-only file permissions. The file contains
only the token. ReleaseKit's profile selects this file as a fallback to
`RELEASEKIT_GITEA_TOKEN`; `.releasekit/` is already ignored. Its optional
`validation.forbidden_files` selection checks that this path is absent from the
committed candidate. Source publication requires no delivery token. The
[configuration reference](config.md) owns credential selection and validator
semantics.
Use a clean clone on `main`. The chosen build toolchain is Go 1.26.10. Make that
local installation the active `go` command for preparation, building the release local installation the active `go` command for preparation, building the release
executable, and candidate validation: executable, and candidate validation:
@@ -43,9 +55,9 @@ 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:
```markdown ```markdown
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/COPYRIGHT) | [COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/vX.Y.Z/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/LICENSE) | [LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/vX.Y.Z/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/docs/oss/attribution.md) [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/vX.Y.Z/docs/oss/attribution.md)
``` ```
Replace every `vX.Y.Z` with the selected version. These are links to the same Replace every `vX.Y.Z` with the selected version. These are links to the same
@@ -96,45 +108,81 @@ for ReleaseKit's first release before any released ReleaseKit executable
exists. It uses the local Go toolchain. Preparation must finish before the exists. It uses the local Go toolchain. Preparation must finish before the
offline validation begins. offline validation begins.
## Resolve failures ## Deliver the hosted release page
A failed push triggers a URL-resolution check against the captured endpoint, After source publication succeeds, deliver the hosted page locally when
then at most one exact authoritative tag query. If the endpoint no longer Woodpecker automation is inactive. From the same repository root, keep the
resolves to itself, recovery reports `unknown` without querying the rewritten worktree clean and `HEAD` at the published tag's commit. Reuse the executable
destination. An exact tag-object match confirms publication even when Git built by the publication command:
reported an error. A proven absent tag is `not_published`; a different object
is a conflict; an unreadable query is `unknown`. The command retains its local
tag and reports the state, phase,
candidate commit, tag object, and captured publication endpoint when available.
Do not retry an unknown result until you resolve the authoritative ref at that
exact endpoint. Copy the reported endpoint value into the variable below using
normal shell quoting; retain the version and tag object from the same result.
The command checks that Git URL rewriting still resolves this value to itself;
the manual check below applies the same rule before querying:
```sh ```sh
releasekit_endpoint='COPY_CAPTURED_ENDPOINT' .releasekit/bin/releasekit deliver vX.Y.Z
releasekit_version='vX.Y.Z'
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
``` ```
A failed URL-equality check or remote query leaves the outcome unresolved. The command uses the configured Gitea destination and token source to deliver
Correct transport configuration before continuing. The reported endpoint may the complete checked-in release note. Report its result separately from the
be different from `origin`'s fetch URL or its current push URL; querying either completed source release. For failed or uncertain delivery, follow the recovery
of those does not establish absence at the captured endpoint. instructions below using the same immutable version.
Compare the returned object ID with the tag object reported by the command. ## Resolve publication failures
An exact match confirms publication. If the ref is absent, the version was not
published; only then may you deliberately remove the unpublished local tag Each publication invocation attempts to save its captured identity before the
with `git tag -d vX.Y.Z` before correcting the candidate or retrying. Do not tag push. The CLI reports the saved record path separately from publication
move, overwrite, or delete a tag that exists remotely. If a different object success or failure and, after a push or immediate-reconciliation error, prints a
uses the version, choose a new version after resolving the conflict. copyable command that selects that exact record. Run that command, or use the
source-built executable prepared above, to make a separate later observation:
```sh
./.releasekit/bin/releasekit reconcile --record '/absolute/path/to/attempt-record.json'
```
The saved evidence fixes the original endpoint and tag identity. Later recovery
uses current supported transport settings for the recorded remote name, but it
does not substitute the current remote URL, inspect the local tag or current
HEAD, load a profile, rerun checks, or change repository state. A completed
observation reports `published`, `not_published`, or `conflict`; an incomplete
observation reports `unknown` with diagnostic context. Resolve an unknown result
by correcting the identified endpoint rewrite, transport configuration, or
missing evidence. For incomplete saved identity, select another record with the
required fields or use complete typed evidence through the Go API.
If command output was lost, list local attempts and explicitly select the
matching record by its module, full tag, and saved identity:
```sh
./.releasekit/bin/releasekit publication-attempts --repo "$PWD"
./.releasekit/bin/releasekit reconcile --record '/absolute/path/from-the-list/attempt-record.json'
```
Discovery only reads the local store. It neither queries remotes nor chooses an
attempt automatically. If the original recovery entry point is unavailable,
select another supported ReleaseKit CLI and pass the same record path explicitly.
An older record may lack the endpoint, tag-object ID, candidate commit, or
another field required for a conclusive result.
A later observation that the tag is absent describes the remote at that time;
it does not prove the version was never published. Never reuse a version known
to have been published, and preserve published tags and contents as immutable.
Keep removal of an unpublished local tag, candidate correction, and retry as
explicit operator decisions after reviewing the record and authoritative
remote state. A conflicting object requires resolving the conflict and selecting
an unused version when appropriate; do not move, overwrite, or delete a
published tag.
If record storage fails, ReleaseKit reports the recording warning separately;
the warning does not change the actual publication outcome. The saved handoff is
then unavailable. A Go caller that retained `PublicationResult.Evidence` can
still pass that typed evidence directly to `ReconcilePublication`. Process
termination before the record is written, removed storage, and older incomplete
records can leave no sufficient saved evidence for later recovery.
Later reconciliation adds no successful-push read-back, polling, CI, consumer
installation, or hosted-delivery gate. Hosted page delivery remains separate.
If validation or a guard fails before this invocation pushes, correct and If validation or a guard fails before this invocation pushes, correct and
commit the candidate, publish `main`, and run the command again. A retained commit the candidate, publish `main`, and run the command again. A retained
local tag still requires the authoritative-state check above before removal; local tag still requires reviewing the selected record and authoritative state
a concurrent publisher may already have published the version. The prepared before deciding whether to remove it. The prepared
Woodpecker workflow builds the CLI from the tagged checkout, then uses Woodpecker workflow builds the CLI from the tagged checkout, then uses
`ci-deliver` for page-only delivery. To activate it after configuring the `ci-deliver` for page-only delivery. To activate it after configuring the
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml` destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`
@@ -148,7 +196,15 @@ git checkout --detach "vX.Y.Z"
GOTOOLCHAIN=local go mod download 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/bin/releasekit deliver "vX.Y.Z"
```
For an older tag whose CLI predates `token_file`, supply the token through its
environment variable when retrying that tagged tool:
```sh
RELEASEKIT_GITEA_TOKEN="$(cat .releasekit/gitea-token)" \
.releasekit/bin/releasekit deliver "vX.Y.Z"
``` ```
The root ignore file excludes `.releasekit/`. Delivery uses the committed note The root ignore file excludes `.releasekit/`. Delivery uses the committed note

36
docs/releases/v0.4.2.md Normal file
View 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)

59
docs/releases/v0.5.0.md Normal file
View File

@@ -0,0 +1,59 @@
# ReleaseKit v0.5.0
## Summary
ReleaseKit adopts `go.maximumdirect.net/releasekit` as its Go module identifier.
The vanity path resolves to the Gitea repository at
`https://forge.maximumdirect.net/base-layer/releasekit`.
## Changes
- The orchestration facade is `go.maximumdirect.net/releasekit`, the runtime
facade is `go.maximumdirect.net/releasekit/buildinfo`, and the executable
package is `go.maximumdirect.net/releasekit/cmd/releasekit`.
- Imports, version-injection linkage, compiled API checks, consumer fixtures,
and maintained release launchers and Woodpecker workflows use the new module
identity.
- ReleaseKit's hosted-page delivery profile and maintainer guidance use Gitea
at `forge.maximumdirect.net`, owner `base-layer`, repository `releasekit`.
## Compatibility
This pre-1.0 minor release changes package import paths and requires consumer
migration. Facade roles, command behavior, configuration formats, and the Go
1.26.0 consumer minimum retain their existing definitions. The runtime version
accessor and builder use the new module's runtime implementation for injection.
Packages imported through different module paths have distinct Go identities.
Coordinate import changes in packages that exchange ReleaseKit facade types,
and update builders and applications using `buildinfo` together.
Published versions through v0.4.2 retain the original
`gitea.maximumdirect.net/eric/ReleaseKit` module declaration. Those versions
continue to describe their original source and use their matching historical
instructions. A vanity-path download of an older tag does not change its module
declaration.
## Upgrade guidance
1. Update application, library, test, and generated imports for the root facade
and `buildinfo` to the new paths. Select v0.5.0 or a later compatible release
using the [adoption guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/docs/consumers/adoption.md#migrating-from-the-original-module-path).
2. For CLI tool dependencies, remove the old tool declaration and register
`go.maximumdirect.net/releasekit/cmd/releasekit` at the selected version.
3. Update copied launchers and Woodpecker workflows together with the module
selection. The maintained examples use
`releasekit_module=go.maximumdirect.net/releasekit`.
4. Run `go mod tidy`, review any old-path dependencies retained transitively,
and commit module and workflow changes together. Keep existing consumer tags
intact; their delivery retries use their recorded module files and matching
workflows.
Source repository and hosted release URLs use the Gitea host; Go imports and
tool dependencies use the vanity path. ReleaseKit's source publication and
hosted-page delivery retain their separate outcomes under the
[maintainer procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/docs/release.md).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.5.0/docs/oss/attribution.md)

125
docs/releases/v0.6.0.md Normal file
View File

@@ -0,0 +1,125 @@
# ReleaseKit v0.6.0
## Summary
ReleaseKit adds file-based delivery credentials, optional checks for forbidden
committed files, neutral delivery coordination with explicit Gitea selection, and
authenticated, read-only credential preflight. It also corrects dependency
preparation, publication recovery, and build cancellation handling.
## Changes
- Delivery profiles can select a plain token file through `token_file`.
Relative paths resolve from the checkout root; a nonblank token environment
value takes precedence. The CLI trims file contents and reports unreadable
or blank files as credential-loading failures. Library callers continue to
supply the token explicitly through the destination.
- `ValidationSpec.ForbiddenFiles` and `validation.forbidden_files` select
literal repository-relative paths that must be absent from the committed
candidate. This optional check can protect a local token-file path. It
inspects committed path metadata independently of module/shared-file inputs;
it does not inspect file contents or history.
- `releasekit.DeliveryRequest.Destination` now uses the facade-owned
`releasekit.DeliveryDestination`. Set `Forge: releasekit.ForgeGitea` for
Gitea delivery. The removed `GiteaDestination` type has no compatibility
alias.
- Delivery profiles must include `"forge": "gitea"`. The selection is
required only when `deliver` or `ci-deliver` is requested; the URL does not
infer a backend. `check`, `build`, and source `publish` remain independent of
delivery selection and credentials.
- `releasekit.PreflightDelivery` allows callers to check a destination without
a checkout or tag. `Deliver` performs the same read-only preflight
automatically. For Gitea, the check authenticates through repository-scoped
reads and makes no test release or asset writes.
- `DeliveryPreflightError` exposes classified failures and retains the
underlying cause. Successful preflight confirms authentication and repository
access plus available permission evidence; it does not guarantee future
release or asset writes. Gitea's code-push permission metadata does not
establish release-write or asset-write permission.
- The CLI keeps `RELEASEKIT_GITEA_TOKEN` as its default token environment
setting and preserves configured environment/file precedence. Selection,
local credential-loading, and classified preflight configuration/access
errors return status 2; unavailable preflight and cancellation during
preflight/preparation return status 1. Later authorization failures follow
delivery outcomes. An unresolved write returns status 3, including when
cancellation prevents reconciliation; known delivery failures return 1.
- Binary `ci-deliver` checks credentials before application dependency
downloads and checks again during delivery. The automatic check now precedes
full local artifact size and digest verification; those checks still finish
before hosted writes.
## Correctness and efficiency
- Dependency preparation now resolves the containing checkout root, including
when called from a subdirectory or filesystem alias. `ModuleDir` is relative
to that root, and empty selection prepares the root module. Standalone use
retains the supplied directory as its base, including Go-only use when Git
is unavailable. Damaged checkout metadata and operational discovery failures
remain errors before dependency downloads.
- Preparation overrides alternate module-file selection from inherited or
persistent `GOFLAGS`, while retaining other effective Go settings. Downloads
therefore use the selected module's ordinary metadata.
- Candidate checking rejects a derived release tag that Git cannot create
before running validation. CI delivery also validates the selected module's
complete tag before dependency preparation.
- Publication recovery treats a peeled-only tag observation as unresolved,
retains the original push failure, and reports `PublicationUnknown` with CLI
status 3. Complete tag observations retain their existing recovery behavior.
- Cancellation and deadline expiration during build source inspection remain
identifiable through `errors.Is`; partial results retain completed artifacts.
Local artifact inspection errors also retain their underlying causes through
delivery.
- Gitea asset operations classify a missing or nonpositive service release ID
as a malformed observation before making an asset request. Page-only release
lookup retains its existing minimal response requirements.
- Asset delivery uses linear pending-asset bookkeeping and sorts only missing
assets once, reducing repeated work during uploads and reconciliation.
## Compatibility
This pre-1.0 minor release changes the public destination type and requires
explicit forge selection for delivery. Gitea is the supported backend.
Preparation callers that previously used a checkout subdirectory as their
implicit module selection must now select that module with `ModuleDir`.
Ambient `GOFLAGS` no longer redirects preparation to an alternate module file.
The module identifier remains `go.maximumdirect.net/releasekit`, and the Go
consumer minimum remains 1.26.0. Receipt schemas and the separate source
publication and hosted-delivery outcomes retain their existing definitions.
## Upgrade guidance
Select v0.6.0 for library and CLI dependencies, then update integrations as
follows:
1. Replace `releasekit.GiteaDestination` with
`releasekit.DeliveryDestination` and set
`Forge: releasekit.ForgeGitea` in each API request.
2. Add `"forge": "gitea"` to every profile used for `deliver` or
`ci-deliver`, including profiles loaded from a tagged CI checkout. Keep the
existing `base_url`, owner, repository, token environment, and token file
settings.
3. If handling preflight failures in Go, use `errors.As` to inspect
`*releasekit.DeliveryPreflightError` and use `errors.Is` or `errors.As` on
its unwrapped cause as needed.
4. Continue to treat successful preflight as evidence of repository access,
not a guarantee that a later write will be accepted. Source publication
continues to use Git authentication and does not require hosted delivery
credentials.
5. For dependency preparation of a nested module, supply its repository-relative
path through `DependencyPreparationRequest.ModuleDir`, even when
`RepositoryDir` already points inside that module. Root-module consumers can
keep `ModuleDir` empty.
6. To adopt file-based credentials, store the token in an ignored local file
and configure `delivery.token_file`. Optionally add that same
repository-relative path to `validation.forbidden_files`. Credential loading
and candidate file validation are independent selections; configuring the
file alone does not enable the validator.
See the [delivery guide](../consumers/delivery.md), [configuration reference](../config.md),
[CLI contract](../cli.md), and [Gitea integration guide](../integrations/gitea.md)
for current behavior.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.6.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.6.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.6.0/docs/oss/attribution.md)

106
docs/releases/v0.7.0.md Normal file
View File

@@ -0,0 +1,106 @@
# ReleaseKit v0.7.0
## Summary
ReleaseKit improves source-publication integrity, cancellation, credential-safe
diagnostics, partial-result reporting, and temporary-file cleanup. It also bounds
retained Go failure diagnostics and remote attachment comparison work while
preserving the existing public facades and configuration formats.
## Publication and Git operations
- Exact tag pushes disable submodule recursion, correcting inherited
`push.recurseSubmodules=only` settings that could report success without
publishing the release tag.
- ReleaseKit-owned Git inspection and preparation use original objects when
replacement refs exist. Committed module identity, release notes, validation
inputs, and build source eligibility use that consistent view. Ordinary dirty
builds remain supported, with their source eligibility reported separately.
- A verified local tag object remains available in `PublicationResult.TagObject`
when later inspection fails. Errors identify a possibly retained local ref
when its object ID could not be established. Failed verification stops before
a push.
- HTTP(S) userinfo is masked in owned Git diagnostics and CLI output, including
literal apostrophes in credentials and failed CI checkout preparation. Native
Git authentication, exact endpoint comparisons, and the raw captured
`PublicationResult.Endpoint` value retain their existing behavior.
- Child environment overlays preserve the working-directory-derived `PWD` on
POSIX systems. Native Git hooks now receive the expected directory environment
when ReleaseKit is invoked from another checkout or module subdirectory.
## Cancellation and diagnostic retention
- Buffered subprocess output has bounded I/O teardown after cancellation or
child exit, including when descendants retain inherited output descriptors.
Piped Git-reader cancellation closes owned pipes and joins child cleanup.
Cancellation remains recognizable through `errors.Is`; ordinary execution
failures preserve their underlying causes.
- A queued blob read rechecks cancellation after acquiring the reader mutex,
preserving a healthy batch session for later reads.
- Status-only Go commands and dependency preparation retain a 1 MiB diagnostic
tail. Failed commands identify omitted earlier output. Existing streaming
output continues to be forwarded in full, and commands requiring returned
output keep their complete capture.
## Builds and hosted delivery
- Completed build artifacts are reported only after hashing succeeds. A hashing
failure identifies the affected output and preserves earlier completed
artifacts and their digests.
- Temporary allocation and cleanup failures retain their paths and underlying
causes, including candidate builds, native version checks, delivery builds,
tag-message files, and build receipts.
- A managed-build cleanup failure after confirmed hosted delivery returns an
error alongside `DeliveryComplete` and the confirmed asset observations. The
CLI reports completed remote work and the local cleanup failure, returning
operational status 1.
- Gitea attachment downloads follow at most ten redirects, retaining cycle
detection and per-origin authorization handling. Limit exhaustion is
unverifiable content.
- Remote attachment comparison reads at most the locally verified expected size
plus one byte. Shorter or longer content conflicts; equal-length content still
requires the expected digest. Read, close, and cancellation errors remain
inspection failures, with existing attempted-write reconciliation preserved.
Shared Git observations, validation sequencing, artifact declaration matching,
and CLI workflows were consolidated internally. Consumer-facing types continue
to belong to the root `releasekit` and runtime `buildinfo` facades.
## Compatibility and upgrade guidance
The module identifier remains `go.maximumdirect.net/releasekit`, and the Go
consumer minimum remains 1.26.0. Existing v0.6.0 API calls, profile fields, and
build receipt formats remain supported. This minor release changes operational
behavior through the diagnostic and redirect budgets and cleanup-error reporting
described above.
When adopting v0.7.0:
1. Update library and CLI dependencies to v0.7.0 using the existing import paths
and profiles.
2. Handle a delivery error together with `DeliveryResult.State`. For
`DeliveryComplete`, address the reported local cleanup path; the confirmed
hosted delivery is already complete. Continue to resolve partial, conflicting,
and unknown outcomes through their existing recovery guidance.
3. Retain candidate validation's streamed `Output` when complete test diagnostics
are needed. Status-only failure text now contains the retained tail and an
omission notice when applicable.
4. When using Git replacement refs, expect release evidence to be evaluated
against original committed objects. A build of dirty or replacement-derived
working content can succeed while remaining ineligible for binary delivery.
5. Inspect the reported local tag ref after a tag-creation or verification
failure, including when `TagObject` is empty. Continue to treat the raw
`PublicationResult.Endpoint` as sensitive when handling it in application
output or logs.
See the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/publish.md),
[build guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/build.md),
[delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/consumers/delivery.md),
[CLI contract](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/cli.md),
and [Gitea integration guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/integrations/gitea.md)
for current behavior. Source publication and hosted delivery retain their
separate outcomes under the [maintainer procedure](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/release.md).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.7.0/docs/oss/attribution.md)

54
docs/releases/v0.8.0.md Normal file
View File

@@ -0,0 +1,54 @@
# ReleaseKit v0.8.0
## Summary
ReleaseKit adds read-only recovery for source-publication attempts. It attempts
to retain evidence before a tag push, lets operators discover and explicitly
select a saved attempt, and observes the original destination without repeating
candidate validation or changing tags. Source publication and hosted delivery
continue to have separate outcomes.
## Publication recovery
- `Publish` returns reusable `PublicationEvidence` and attempts to save a
private record. The result reports the saved path or a recording error
separately from the publication outcome. Go callers can load or list records
and reconcile retained evidence through the root facade.
- The CLI adds `publication-attempts` for local discovery and `reconcile` for
an explicitly selected record. Both support human-readable and structured
output. `publish --record-dir` selects an alternate record directory, and
failed publication can print a recovery command for the saved attempt.
- Recovery uses the captured endpoint and full tag with current supported Git
transport settings. It reports publication, current absence, conflict, or an
unresolved observation. Current absence does not prove that a version was
never published; published versions and their contents remain immutable.
- Records preserve exact endpoint and path bytes. Ordinary UTF-8 evidence uses
record format 1; format 2 preserves other bytes. Recovery diagnostics redact
HTTP(S) URL userinfo, while private records and typed evidence can contain
sensitive endpoints. Operators control record retention and removal.
## Consumer tools and compatibility
The module path remains `go.maximumdirect.net/releasekit`, and the Go consumer
minimum remains 1.26.0. Existing publication calls and release profiles remain
supported. Publication now attempts to write a private record by default; a
storage failure is reported separately and does not change the publication
result. Applications handling results should inspect `RecordError` when a saved
recovery handoff matters.
Consumers with the CLI registered as a Go tool in their module can run it with
`go tool releasekit`.
The maintained multi-module launcher and Woodpecker examples use the selected
module's tool version. Register the CLI before generating a vendor tree when
using a vendored tool. Older recovery CLIs that only support record format 1
cannot load format 2 records; use a CLI that supports format 2.
See the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/publish.md),
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/cli.md),
[publication record formats](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/integrations/publication-records.md),
and [consumer adoption guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/adoption.md)
for usage and recovery details.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/oss/attribution.md)

View File

@@ -0,0 +1,67 @@
# Deferred resource work
These two proposals remain deferred after the completed audit remediation for
v0.7.0. Their assessments are complete; production limits and exclusion indexing
await a future decision supported by consumer needs. The assessment evidence
was recorded in commit `9dc87ae`.
## Forge JSON response budgets
**Status:** Deferred; local sizing assessment complete.
Forge JSON decoding can consume memory proportional to response size. Release
notes and unpaginated attachment lists have no established supported maximum.
A future proposal should establish an appropriate response envelope before
selecting byte/time budgets or configuration. Any limit must retain context
cancellation, useful error classification, and uncertain-write reconciliation:
a rejected response does not establish that a create or upload failed remotely.
The [Gitea fixtures](../../internal/gitea/backend_test.go) construct repository,
release, tag, and attachment responses. Representative encoded fixtures measured
81 bytes for repository identity, 136 with permissions, 125 for a sparse release,
144 for a populated release, 73 for a one-item attachment list, and 62 for an
upload response, including the encoder's newline. Four literal release-ID
fixtures measured 21, 28, 29, and 29 bytes without a newline. Generated URLs,
notes, and lists vary with fixture inputs.
The checked-in v0.1.0–v0.6.0 release notes measured 1,235–7,899 bytes during the
assessment. These observations describe local examples and establish no
deployment or protocol maximum. Production response caps remain deferred.
## File-size exclusion indexing
**Status:** Deferred; benchmark assessment complete.
The current [exclusion scan](../../internal/validation/file_size.go) compares
each selected file with the normalized exclusions until a match is found.
The adoption decision weighs measured speedups against allocation and
path-walking costs for consumer workloads. Preserve literal component boundaries,
module-relative normalization, shared inputs, and the first oversized-file result.
The [benchmark-only prototype](../../internal/validation/file_size_benchmark_test.go)
compares the scan with a normalized set and ancestor lookups. It covers 1,000
and 10,000 paths, 1/10/100/1,000 exclusions, shallow/deep paths, early matches,
and nonmatches. Each measured operation includes normalization and one index
construction where applicable; fixture strings are prepared outside timing.
On Linux/amd64, Go 1.26.8, Intel Core i5-7260U, 64 combinations ran with three
samples each using:
```sh
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go test ./internal/validation \
-run '^$' -bench '^BenchmarkFileSizeExclusions$' -benchtime=100ms -count=3
```
Representative median times and allocations were:
| Paths / exclusions / case | Scan | Index |
| --- | --- | --- |
| 1,000 / 1 / shallow early match | 24.5 µs, 16 B, 1 allocation | 44.0 µs, 16 B, 1 allocation |
| 1,000 / 1 / deep nonmatch | 25.7 µs, 16 B, 1 allocation | 119.0 µs, 16 B, 1 allocation |
| 10,000 / 1,000 / shallow early match | 270 µs, 16,384 B, 1 allocation | 564 µs, 70,992 B, 6 allocations |
| 10,000 / 1,000 / shallow nonmatch | 227.0 ms, 16,384 B, 1 allocation | 521 µs, 70,992 B, 6 allocations |
| 10,000 / 1,000 / deep nonmatch | 233.4 ms, 16,384 B, 1 allocation | 1.36 ms, 70,992 B, 6 allocations |
The prototype was slower for small lists and early matches, and faster for
large nonmatching lists with higher allocation costs. Production matching
remains unchanged pending an adoption decision.

View File

@@ -15,13 +15,13 @@ minimum.
## Select the release tool ## Select the release tool
The module files are setup skeletons. Set `RELEASEKIT_TOOL_VERSION` to a released The module files are setup skeletons. Set `RELEASEKIT_TOOL_VERSION` to a released
ReleaseKit version containing module-release support, then register that tool in ReleaseKit version at or after the v0.5.0 vanity-path migration, then register
each consumer module before running the launcher or workflow: that tool in each consumer module before running the launcher or workflow:
```sh ```sh
: "${RELEASEKIT_TOOL_VERSION:?Select a ReleaseKit release with module-release support}" : "${RELEASEKIT_TOOL_VERSION:?Select a ReleaseKit release with module-release support}"
for module_dir in . integrations/ckconfig integrations/core/v2; do 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" get -tool "go.maximumdirect.net/releasekit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
go -C "$module_dir" mod tidy go -C "$module_dir" mod tidy
done done
``` ```
@@ -72,11 +72,14 @@ Then select a module and semantic version with the shared launcher:
The launcher copies the selected module's `go.mod` and `go.sum` to temporary The launcher copies the selected module's `go.mod` and `go.sum` to temporary
alternate module files outside the checkout, resolves ReleaseKit from that alternate module files outside the checkout, resolves ReleaseKit from that
module's local dependency selection, and builds the tool there. It invokes module's local dependency selection, adds the CLI tool declaration to that
`publish` with the repository profile and `--expected-module` so the library temporary copy, and prepares its dependencies. It uses `go tool releasekit`
can verify that the profile agrees with the selected table row. The temporary with the alternate module file to invoke `publish` with the repository profile
files and tool are removed on exit. As with any `publish` invocation, commit the and `--expected-module` so the library can verify that the profile agrees with
selected module's release note and prepare its repository before publishing. the selected table row. Go builds and caches the executable and preserves its
exit status; the launcher removes the temporary metadata 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, If an integration depends on a sibling release, publish the dependency first,
then publish the integration with the version that consumes it. For example, then publish the integration with the version that consumes it. For example,
@@ -86,11 +89,13 @@ This declaration does not infer dependency order or rerun the standard suite.
The maintained [Woodpecker example](woodpecker.yml) uses the same map and The maintained [Woodpecker example](woodpecker.yml) uses the same map and
selector for root, `ckconfig`, and major-version tags. It verifies and detaches 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 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 outside the checkout, and runs the ReleaseKit version selected there through
uses `ci-deliver --prepared` with the mapped profile and expected physical module. `go tool releasekit`. 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 The `core-v2` profile demonstrates binary delivery; the other profiles are
page-only. Before enabling the workflow, configure each selected profile's page-only. Before enabling the workflow, configure each selected profile's
`delivery` destination and add the Woodpecker repository secret `delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository,
then add the Woodpecker repository secret
`releasekit_gitea_token`; the workflow exposes it as `releasekit_gitea_token`; the workflow exposes it as
`RELEASEKIT_GITEA_TOKEN` only in the delivery step. `RELEASEKIT_GITEA_TOKEN` only in the delivery step.
@@ -108,9 +113,9 @@ modules:
: "${RELEASEKIT_SOURCE:?Set the absolute ReleaseKit checkout path}" : "${RELEASEKIT_SOURCE:?Set the absolute ReleaseKit checkout path}"
for module_dir in . integrations/ckconfig integrations/core/v2; do for module_dir in . integrations/ckconfig integrations/core/v2; do
go -C "$module_dir" mod edit \ go -C "$module_dir" mod edit \
-require=gitea.maximumdirect.net/eric/ReleaseKit@v0.0.0 \ -require=go.maximumdirect.net/releasekit@v0.0.0 \
-replace="gitea.maximumdirect.net/eric/ReleaseKit=$RELEASEKIT_SOURCE" \ -replace="go.maximumdirect.net/releasekit=$RELEASEKIT_SOURCE" \
-tool=gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit -tool=go.maximumdirect.net/releasekit/cmd/releasekit
GOTOOLCHAIN=local GOWORK=off go -C "$module_dir" mod tidy GOTOOLCHAIN=local GOWORK=off go -C "$module_dir" mod tidy
done done
``` ```

View File

@@ -3,7 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "go.maximumdirect.net/releasekit/buildinfo"
) )
func main() { func main() {

View File

@@ -31,18 +31,17 @@ else
: > "$bootstrap_dir/releasekit.sum" : > "$bootstrap_dir/releasekit.sum"
fi fi
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit releasekit_module=go.maximumdirect.net/releasekit
( (
cd "$module_path" cd "$module_path"
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module" go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
go build -mod=mod -modfile="$bootstrap_dir/releasekit.mod" \ go mod edit -modfile="$bootstrap_dir/releasekit.mod" -tool="$releasekit_module/cmd/releasekit"
-o "$bootstrap_dir/releasekit" "$releasekit_module/cmd/releasekit" go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -deps "$releasekit_module/cmd/releasekit" > /dev/null
go tool -modfile="$bootstrap_dir/releasekit.mod" releasekit publish \
--repo "$repository_dir" \
--config "$profile_path" \
--expected-module "$module_dir" \
"$version"
) )
"$bootstrap_dir/releasekit" publish \
--repo "$repository_dir" \
--config "$profile_path" \
--expected-module "$module_dir" \
"$version"

View File

@@ -8,7 +8,7 @@ when:
steps: steps:
- name: deliver - name: deliver
image: golang:1.26.8-bookworm image: golang:1.26.10-bookworm
environment: environment:
RELEASEKIT_GITEA_TOKEN: RELEASEKIT_GITEA_TOKEN:
from_secret: releasekit_gitea_token from_secret: releasekit_gitea_token
@@ -20,8 +20,12 @@ steps:
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}" git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
git fetch origin "refs/tags/$${CI_COMMIT_TAG}: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}") tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]') expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_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}" git checkout --detach "$${tag_commit}"
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
test -z "$(git status --porcelain --untracked-files=all)" test -z "$(git status --porcelain --untracked-files=all)"
@@ -36,28 +40,26 @@ steps:
if [ "$${module_dir}" != . ]; then if [ "$${module_dir}" != . ]; then
module_path="$${CI_WORKSPACE}/$${module_dir}" module_path="$${CI_WORKSPACE}/$${module_dir}"
fi fi
cp "$${module_path}/go.mod" "$${bootstrap_dir}/go.mod" cp "$${module_path}/go.mod" "$${bootstrap_dir}/releasekit.mod"
if [ -f "$${module_path}/go.sum" ]; then if [ -f "$${module_path}/go.sum" ]; then
cp "$${module_path}/go.sum" "$${bootstrap_dir}/go.sum" cp "$${module_path}/go.sum" "$${bootstrap_dir}/releasekit.sum"
else else
: > "$${bootstrap_dir}/go.sum" : > "$${bootstrap_dir}/releasekit.sum"
fi fi
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit releasekit_module=go.maximumdirect.net/releasekit
( (
cd "$${module_path}" cd "$${module_path}"
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \ go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \
--repo "$${CI_WORKSPACE}" \
--config "$${profile_path}" \
--expected-module "$${module_dir}" \
--prepared \
--commit "$${expected_commit}" \
"$${CI_COMMIT_TAG}"
) )
"$${bootstrap_dir}/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \
--config "$${profile_path}" \
--expected-module "$${module_dir}" \
--prepared \
--commit "$${CI_COMMIT_SHA}" \
"$${CI_COMMIT_TAG}"

View File

@@ -7,6 +7,7 @@
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"] "targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
}, },
"delivery": { "delivery": {
"forge": "gitea",
"base_url": "https://gitea.example", "base_url": "https://gitea.example",
"owner": "example", "owner": "example",
"repository": "example", "repository": "example",

View File

@@ -2,6 +2,7 @@
"product": "Example", "product": "Example",
"remote": "origin", "remote": "origin",
"delivery": { "delivery": {
"forge": "gitea",
"base_url": "https://gitea.example", "base_url": "https://gitea.example",
"owner": "example", "owner": "example",
"repository": "example", "repository": "example",

View File

@@ -4,7 +4,7 @@ when:
steps: steps:
- name: deliver - name: deliver
image: golang:1.26.8-bookworm image: golang:1.26.10-bookworm
environment: environment:
RELEASEKIT_GITEA_TOKEN: RELEASEKIT_GITEA_TOKEN:
from_secret: releasekit_gitea_token from_secret: releasekit_gitea_token
@@ -15,9 +15,13 @@ steps:
test -n "$${CI_COMMIT_SHA}" test -n "$${CI_COMMIT_SHA}"
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}" git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}" git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
tag_commit=$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}") tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]') expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_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}" git checkout --detach "$${tag_commit}"
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
test -z "$(git status --porcelain --untracked-files=all)" test -z "$(git status --porcelain --untracked-files=all)"
@@ -30,5 +34,5 @@ steps:
"$${bootstrap_dir}/releasekit" ci-deliver \ "$${bootstrap_dir}/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \ --repo "$${CI_WORKSPACE}" \
--prepared \ --prepared \
--commit "$${CI_COMMIT_SHA}" \ --commit "$${expected_commit}" \
"$${CI_COMMIT_TAG}" "$${CI_COMMIT_TAG}"

View File

@@ -1,6 +1,6 @@
matrix: matrix:
include: include:
- GO_IMAGE: golang:1.26.8-bookworm - GO_IMAGE: golang:1.26.10-bookworm
when: when:
- event: tag - event: tag
@@ -19,32 +19,34 @@ steps:
test -n "$${CI_COMMIT_SHA}" test -n "$${CI_COMMIT_SHA}"
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}" git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}" git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
tag_commit=$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}") tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]') expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_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}" git checkout --detach "$${tag_commit}"
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
test -z "$(git status --porcelain --untracked-files=all)" test -z "$(git status --porcelain --untracked-files=all)"
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX") bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
trap 'rm -rf "$${bootstrap_dir}"' EXIT trap 'rm -rf "$${bootstrap_dir}"' EXIT
cp go.mod "$${bootstrap_dir}/go.mod" cp go.mod "$${bootstrap_dir}/releasekit.mod"
if [ -f go.sum ]; then if [ -f go.sum ]; then
cp go.sum "$${bootstrap_dir}/go.sum" cp go.sum "$${bootstrap_dir}/releasekit.sum"
else else
: > "$${bootstrap_dir}/go.sum" : > "$${bootstrap_dir}/releasekit.sum"
fi fi
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
export GOWORK=off export GOWORK=off
export GOTOOLCHAIN=local export GOTOOLCHAIN=local
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit releasekit_module=go.maximumdirect.net/releasekit
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \ go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit" go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
"$${bootstrap_dir}/releasekit" ci-deliver \ go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \
--repo "$${CI_WORKSPACE}" \ --repo "$${CI_WORKSPACE}" \
--prepared \ --prepared \
--commit "$${CI_COMMIT_SHA}" \ --commit "$${expected_commit}" \
"$${CI_COMMIT_TAG}" "$${CI_COMMIT_TAG}"

2
go.mod
View File

@@ -1,4 +1,4 @@
module gitea.maximumdirect.net/eric/ReleaseKit module go.maximumdirect.net/releasekit
go 1.26.0 go 1.26.0

View File

@@ -15,7 +15,7 @@ import (
"strings" "strings"
) )
const modulePath = "gitea.maximumdirect.net/eric/ReleaseKit" const modulePath = "go.maximumdirect.net/releasekit"
var facades = []string{modulePath, modulePath + "/buildinfo"} var facades = []string{modulePath, modulePath + "/buildinfo"}
@@ -226,6 +226,33 @@ func checkImportDirections(packages []listedPackage) error {
} }
visit(modulePath + "/buildinfo") visit(modulePath + "/buildinfo")
for _, root := range []string{
modulePath + "/internal/delivery",
modulePath + "/internal/forge",
modulePath + "/internal/artifact",
} {
seen := make(map[string]bool)
var visitDelivery func(string)
visitDelivery = func(path string) {
if seen[path] {
return
}
seen[path] = true
if path == modulePath+"/internal/gitea" {
violations = append(violations, fmt.Sprintf("shared package %s depends on Gitea adapter", root))
return
}
pkg, ok := byPath[path]
if !ok {
return
}
for _, imported := range pkg.Imports {
visitDelivery(imported)
}
}
visitDelivery(root)
}
if len(violations) == 0 { if len(violations) == 0 {
return nil return nil
} }
@@ -254,7 +281,7 @@ func commandInternalRoot(path string) string {
func forbiddenRuntimeDependency(path string) bool { func forbiddenRuntimeDependency(path string) bool {
for _, part := range strings.Split(strings.ToLower(path), "/") { for _, part := range strings.Split(strings.ToLower(path), "/") {
switch part { switch part {
case "orchestration", "orchestrator", "engine", "release", "process", "build", "builder", "gitea", "delivery", "cli": case "orchestration", "orchestrator", "engine", "release", "process", "build", "builder", "gitea", "forge", "delivery", "cli":
return true return true
} }
} }

View File

@@ -10,6 +10,26 @@ import (
"testing" "testing"
) )
func TestRuntimeFacadeCannotDependOnForge(t *testing.T) {
for _, indirect := range []bool{false, true} {
name := "direct"
dependency := modulePath + "/internal/forge"
packages := []listedPackage{{ImportPath: dependency}}
if indirect {
name = "transitive"
packages = append(packages, listedPackage{ImportPath: modulePath + "/internal/runtime", Imports: []string{dependency}})
dependency = modulePath + "/internal/runtime"
}
packages = append(packages, listedPackage{ImportPath: modulePath + "/buildinfo", Imports: []string{dependency}})
t.Run(name, func(t *testing.T) {
err := checkImportDirections(packages)
if err == nil || !strings.Contains(err.Error(), "buildinfo") || !strings.Contains(err.Error(), "/internal/forge") {
t.Fatalf("dependency check = %v, want excluded forge runtime dependency", err)
}
})
}
}
func TestExportedContractBoundaries(t *testing.T) { func TestExportedContractBoundaries(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -162,6 +182,30 @@ func TestRuntimeFacadeCannotDependOnExternalPackages(t *testing.T) {
} }
} }
func TestSharedDeliveryPackagesCannotDependOnGitea(t *testing.T) {
allowed := []listedPackage{
{ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/forge", modulePath + "/internal/artifact"}},
{ImportPath: modulePath + "/internal/forge"},
{ImportPath: modulePath + "/internal/artifact"},
{ImportPath: modulePath + "/internal/gitea"},
}
if err := checkImportDirections(allowed); err != nil {
t.Fatalf("shared delivery boundary rejected neutral dependencies: %v", err)
}
violating := []listedPackage{
{ImportPath: modulePath + "/internal/delivery", Imports: []string{modulePath + "/internal/policy"}},
{ImportPath: modulePath + "/internal/policy", Imports: []string{modulePath + "/internal/gitea"}},
{ImportPath: modulePath + "/internal/forge"},
{ImportPath: modulePath + "/internal/artifact"},
{ImportPath: modulePath + "/internal/gitea"},
}
err := checkImportDirections(violating)
if err == nil || !strings.Contains(err.Error(), "shared package "+modulePath+"/internal/delivery depends on Gitea adapter") {
t.Fatalf("checkImportDirections() error = %v, want transitive shared-delivery violation", err)
}
}
func checkFixture(t *testing.T, facadeSource string) []string { func checkFixture(t *testing.T, facadeSource string) []string {
t.Helper() t.Helper()
fset := token.NewFileSet() fset := token.NewFileSet()

View File

@@ -90,3 +90,30 @@ func ExpectedBuild(binaryName, version string, targets []Target) []Declaration {
} }
return declarations return declarations
} }
// ValidateDeclarations matches recorded artifact identities to selected targets.
func ValidateDeclarations(binaryName, version string, targets []Target, recorded []Declaration) error {
if len(recorded) != len(targets) {
return fmt.Errorf("artifact and target counts do not match")
}
want := make(map[Target]string, len(targets))
for _, declaration := range ExpectedBuild(binaryName, version, targets) {
target := declaration.Target
if !ValidComponent(target.OS) || !ValidComponent(target.Arch) {
return fmt.Errorf("invalid target %q/%q", target.OS, target.Arch)
}
if _, exists := want[target]; exists {
return fmt.Errorf("repeats target %q/%q", target.OS, target.Arch)
}
want[target] = declaration.Filename
}
seen := make(map[Target]bool, len(recorded))
for _, declaration := range recorded {
name, ok := want[declaration.Target]
if !ok || seen[declaration.Target] || declaration.Filename != name {
return fmt.Errorf("artifact %q does not match a selected target and its declared name", declaration.Filename)
}
seen[declaration.Target] = true
}
return nil
}

View File

@@ -56,3 +56,31 @@ func TestValidComponentPreservesBuildAndReceiptRules(t *testing.T) {
} }
} }
} }
func TestValidateDeclarationsMatchesTargetsAndNames(t *testing.T) {
linux := Target{OS: "linux", Arch: "amd64"}
windows := Target{OS: "windows", Arch: "arm64"}
first := Declaration{Target: linux, Filename: "app-v1.2.3-linux-amd64"}
second := Declaration{Target: windows, Filename: "app-v1.2.3-windows-arm64.exe"}
for _, test := range []struct {
name string
targets []Target
recorded []Declaration
valid bool
}{
{"reordered", []Target{linux, windows}, []Declaration{second, first}, true},
{"count", []Target{linux, windows}, []Declaration{first}, false},
{"duplicate target", []Target{linux, linux}, []Declaration{first, first}, false},
{"duplicate artifact", []Target{linux, windows}, []Declaration{first, first}, false},
{"incorrect name", []Target{linux}, []Declaration{{Target: linux, Filename: "other"}}, false},
{"unknown target", []Target{linux}, []Declaration{second}, false},
{"unsafe target", []Target{{OS: "linux/other", Arch: "amd64"}}, []Declaration{{Target: linux, Filename: first.Filename}}, false},
} {
t.Run(test.name, func(t *testing.T) {
err := ValidateDeclarations("app", "v1.2.3", test.targets, test.recorded)
if (err == nil) != test.valid {
t.Fatalf("ValidateDeclarations() error = %v, valid = %t", err, test.valid)
}
})
}
}

141
internal/artifact/assets.go Normal file
View File

@@ -0,0 +1,141 @@
package artifact
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
var (
// ErrInvalidAsset identifies an invalid asset declaration or unreadable
// local path.
ErrInvalidAsset = errors.New("invalid asset")
// ErrAssetConflict identifies local content that does not match its
// declared identity.
ErrAssetConflict = errors.New("asset content conflict")
)
// ExpectedAsset describes the local content declared for hosted delivery.
// Content is populated for inline assets such as the generated checksum
// manifest; Path identifies file content for later streaming uploads.
type ExpectedAsset struct {
Filename string
SHA256 string
Size int64
Path string
Content []byte
}
// BuildManifest creates the deterministic checksum file for the declared
// binaries. An empty binary set produces no manifest.
func BuildManifest(binaries []ExpectedAsset) (ExpectedAsset, error) {
if len(binaries) == 0 {
return ExpectedAsset{}, nil
}
ordered := append([]ExpectedAsset(nil), binaries...)
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
var body strings.Builder
seen := make(map[string]struct{}, len(ordered))
for _, binary := range ordered {
if !validAssetName(binary.Filename) || binary.Filename == ManifestFilename || !validSHA256(binary.SHA256) {
return ExpectedAsset{}, fmt.Errorf("%w: invalid binary identity for %q", ErrInvalidAsset, binary.Filename)
}
if _, exists := seen[binary.Filename]; exists {
return ExpectedAsset{}, fmt.Errorf("%w: duplicate binary filename %q", ErrInvalidAsset, binary.Filename)
}
seen[binary.Filename] = struct{}{}
fmt.Fprintf(&body, "%s %s\n", binary.SHA256, binary.Filename)
}
content := []byte(body.String())
return ExpectedAsset{Filename: ManifestFilename, SHA256: digest(content), Size: int64(len(content)), Content: content}, nil
}
// ValidateExpectedAssets checks the declarations before they are compared
// with remote content or uploaded.
func ValidateExpectedAssets(expected []ExpectedAsset) error {
seen := make(map[string]struct{}, len(expected))
for _, item := range expected {
if !validAssetName(item.Filename) || !validSHA256(item.SHA256) || item.Size < 0 {
return fmt.Errorf("%w: %q", ErrInvalidAsset, item.Filename)
}
if _, duplicate := seen[item.Filename]; duplicate {
return fmt.Errorf("%w: duplicate expected asset %q", ErrInvalidAsset, item.Filename)
}
seen[item.Filename] = struct{}{}
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
return fmt.Errorf("%w: expected content does not match identity for %q", ErrInvalidAsset, item.Filename)
}
}
return nil
}
// VerifyLocalAssets checks that every expected file still matches its declared
// size and digest before hosted writes begin.
func VerifyLocalAssets(assets []ExpectedAsset) error {
if err := ValidateExpectedAssets(assets); err != nil {
return err
}
for _, asset := range assets {
if err := verifyLocalAsset(asset); err != nil {
return err
}
}
return nil
}
func verifyLocalAsset(asset ExpectedAsset) error {
if len(asset.Content) > 0 {
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
return fmt.Errorf("%w: local content does not match declared asset %q", ErrAssetConflict, asset.Filename)
}
return nil
}
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
return fmt.Errorf("%w: local path for %q does not match its filename", ErrInvalidAsset, asset.Filename)
}
file, err := os.Open(asset.Path)
if err != nil {
return fmt.Errorf("%w: open %q: %w", ErrInvalidAsset, asset.Filename, err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return fmt.Errorf("%w: stat %q: %w", ErrInvalidAsset, asset.Filename, err)
}
if !info.Mode().IsRegular() || info.Size() != asset.Size {
return fmt.Errorf("%w: local file does not match declared size for %q", ErrAssetConflict, asset.Filename)
}
hasher := sha256.New()
size, err := io.Copy(hasher, file)
if err != nil {
return fmt.Errorf("%w: read %q: %w", ErrInvalidAsset, asset.Filename, err)
}
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
return fmt.Errorf("%w: local content does not match declared asset %q", ErrAssetConflict, asset.Filename)
}
return nil
}
func validAssetName(name string) bool {
return name != "" && name != "." && name != ".." && path.Base(name) == name && !strings.ContainsAny(name, "\\\r\n\x00")
}
func validSHA256(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil && strings.ToLower(value) == value
}
func digest(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,93 @@
package artifact
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) {
manifest, err := BuildManifest([]ExpectedAsset{
assetFor("zeta.bin", []byte("zeta")),
assetFor("alpha.bin", []byte("alpha")),
})
if err != nil {
t.Fatal(err)
}
want := assetDigest([]byte("alpha")) + " alpha.bin\n" + assetDigest([]byte("zeta")) + " zeta.bin\n"
if string(manifest.Content) != want || manifest.Filename != ManifestFilename || manifest.SHA256 != assetDigest([]byte(want)) || manifest.Size != int64(len(want)) {
t.Fatalf("manifest = %#v, want content %q", manifest, want)
}
empty, err := BuildManifest(nil)
if err != nil || empty.Filename != "" || len(empty.Content) != 0 {
t.Fatalf("empty manifest = %#v, error = %v", empty, err)
}
}
func TestBuildManifestRejectsInvalidOrDuplicateDeclarations(t *testing.T) {
for _, test := range []struct {
name string
assets []ExpectedAsset
}{
{name: "invalid filename", assets: []ExpectedAsset{assetFor("../app.bin", []byte("app"))}},
{name: "manifest filename", assets: []ExpectedAsset{assetFor(ManifestFilename, []byte("app"))}},
{name: "invalid digest", assets: []ExpectedAsset{{Filename: "app.bin", SHA256: "not-a-digest"}}},
{name: "duplicate filename", assets: []ExpectedAsset{assetFor("app.bin", []byte("one")), assetFor("app.bin", []byte("two"))}},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := BuildManifest(test.assets); err == nil {
t.Fatal("BuildManifest() succeeded for invalid declarations")
}
})
}
}
func TestVerifyLocalAssetsRejectsSameSizeTampering(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.bin")
original := []byte("alpha")
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
asset := assetFor(filepath.Base(path), original)
asset.Content = nil
asset.Path = path
if err := VerifyLocalAssets([]ExpectedAsset{asset}); err != nil {
t.Fatal(err)
}
tampered := []byte("bravo")
if err := os.WriteFile(path, tampered, 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyLocalAssets([]ExpectedAsset{asset}); err == nil || !strings.Contains(err.Error(), "does not match declared asset") {
t.Fatalf("VerifyLocalAssets() error = %v, want content mismatch", err)
}
}
func TestValidateExpectedAssetsRejectsDuplicateAndMismatchedInlineContent(t *testing.T) {
asset := assetFor("app.bin", []byte("alpha"))
if err := ValidateExpectedAssets([]ExpectedAsset{asset, asset}); err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("ValidateExpectedAssets() error = %v, want duplicate", err)
}
asset.Content = []byte("bravo")
if err := ValidateExpectedAssets([]ExpectedAsset{asset}); err == nil || !strings.Contains(err.Error(), "does not match identity") {
t.Fatalf("ValidateExpectedAssets() error = %v, want content mismatch", err)
}
}
func assetFor(filename string, content []byte) ExpectedAsset {
return ExpectedAsset{
Filename: filename,
SHA256: assetDigest(content),
Size: int64(len(content)),
Content: append([]byte(nil), content...),
}
}
func assetDigest(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}

View File

@@ -2,6 +2,7 @@
package build package build
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
@@ -9,16 +10,17 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "go.maximumdirect.net/releasekit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "go.maximumdirect.net/releasekit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" "go.maximumdirect.net/releasekit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "go.maximumdirect.net/releasekit/internal/releaseidentity"
"go.maximumdirect.net/releasekit/internal/tempdir"
"go.maximumdirect.net/releasekit/internal/versioncontract"
) )
type Target = artifact.Target type Target = artifact.Target
@@ -48,27 +50,25 @@ type Result struct {
SourceVerified bool SourceVerified bool
Settings artifact.Settings Settings artifact.Settings
Version string Version string
MainPackage string
BinaryName string
OutputDir string OutputDir string
Targets []Target
Artifacts []Artifact Artifacts []Artifact
Compiler string Compiler string
} }
func BuildResolved(ctx context.Context, request Request) (Result, error) { func BuildResolved(ctx context.Context, request Request) (Result, error) {
return buildResolved(ctx, request, os.RemoveAll)
}
func buildResolved(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr error) {
identity := request.Identity identity := request.Identity
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets) settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
result := Result{ result = Result{
RepositoryDir: identity.RepositoryDir, RepositoryDir: identity.RepositoryDir,
ModuleDir: identity.ModuleDir, ModuleDir: identity.ModuleDir,
ModulePath: identity.ModulePath, ModulePath: identity.ModulePath,
Tag: identity.Tag, Tag: identity.Tag,
Settings: settings, Settings: settings,
Version: identity.Version, Version: identity.Version,
MainPackage: settings.MainPackage,
BinaryName: settings.BinaryName,
Targets: settings.Targets,
} }
if !versioncontract.ValidStableVersion(identity.Version) { if !versioncontract.ValidStableVersion(identity.Version) {
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version) return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
@@ -76,14 +76,14 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" { if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
return result, errors.New("build requires a resolved module identity") return result, errors.New("build requires a resolved module identity")
} }
if len(result.Targets) != 0 { if len(settings.Targets) != 0 {
if settings.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(settings.BinaryName) { if !artifact.ValidComponent(settings.BinaryName) {
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName) return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
} }
for _, target := range result.Targets { for _, target := range settings.Targets {
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) { if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch) return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch)
} }
@@ -102,17 +102,17 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
return result, fmt.Errorf("resolve module directory: %w", err) return result, fmt.Errorf("resolve module directory: %w", err)
} }
result.ModuleDir = moduleDir result.ModuleDir = moduleDir
commit, err := gitCommit(ctx, repositoryDir) commit, err := git.CaptureHead(ctx, repositoryDir)
if err != nil { if err != nil {
return result, err return result, fmt.Errorf("read source commit: %w", err)
} }
result.CandidateCommit = commit result.CandidateCommit = commit
cleanBefore, err := cleanSource(ctx, repositoryDir) statusBefore, err := git.WorktreeStatus(ctx, repositoryDir)
if err != nil { if err != nil {
return result, err return result, fmt.Errorf("inspect build source: %w", err)
} }
if len(result.Targets) == 0 { if len(settings.Targets) == 0 {
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir) result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
return result, nil return result, nil
} }
@@ -143,7 +143,7 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
goHost := goTool.HostTarget() goHost := goTool.HostTarget()
host := Target{OS: goHost.OS, Arch: goHost.Arch} host := Target{OS: goHost.OS, Arch: goHost.Arch}
for index, target := range result.Targets { for index, target := range settings.Targets {
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)
} }
@@ -156,10 +156,10 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
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}
result.Artifacts = append(result.Artifacts, artifact) if err := hashArtifact(&artifact); err != nil {
if err := hashArtifact(&result.Artifacts[len(result.Artifacts)-1]); err != nil {
return result, fmt.Errorf("hash build output %q: %w", path, err) return result, fmt.Errorf("hash build output %q: %w", path, err)
} }
result.Artifacts = append(result.Artifacts, artifact)
} }
var smokePath string var smokePath string
@@ -174,7 +174,11 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
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 func() {
if err := remove(temporaryDir); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("remove native smoke directory %q: %w", temporaryDir, err))
}
}()
smokePath = filepath.Join(temporaryDir, settings.BinaryName) smokePath = filepath.Join(temporaryDir, settings.BinaryName)
if host.OS == "windows" { if host.OS == "windows" {
smokePath += ".exe" smokePath += ".exe"
@@ -192,30 +196,18 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.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 := git.CaptureHead(ctx, repositoryDir)
if err != nil { if err != nil {
return result, err return result, fmt.Errorf("read source commit: %w", err)
} }
cleanAfter, err := cleanSource(ctx, repositoryDir) statusAfter, err := git.WorktreeStatus(ctx, repositoryDir)
if err != nil { if err != nil {
return result, err return result, fmt.Errorf("inspect build source: %w", err)
} }
result.SourceVerified = cleanBefore && cleanAfter && commitAfter == commit result.SourceVerified = statusBefore == "" && statusAfter == "" && commitAfter == commit
return result, nil return result, nil
} }
func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
output, err := command.Output()
if err != nil {
if ctx.Err() != nil {
return "", fmt.Errorf("read source commit: %w", ctx.Err())
}
return "", fmt.Errorf("read source commit: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, 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, moduleDir, 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,
@@ -238,26 +230,14 @@ func hashArtifact(artifact *Artifact) error {
} }
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error { func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
command := exec.CommandContext(ctx, binaryPath, "version") var output bytes.Buffer
output, err := command.CombinedOutput() err := process.Run(ctx, process.Invocation{Args: []string{binaryPath, "version"}, Stdout: &output, Stderr: &output})
if err != nil { if err != nil {
if ctx.Err() != nil { return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(output.String()))
return fmt.Errorf("run native version check: %w", ctx.Err())
}
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(string(output)))
} }
want := binaryName + " " + version + "\n" want := binaryName + " " + version + "\n"
if string(output) != want { if output.String() != want {
return fmt.Errorf("native version output = %q, want %q", output, want) return fmt.Errorf("native version output = %q, want %q", output.String(), want)
} }
return nil return nil
} }
func cleanSource(ctx context.Context, repositoryDir string) (bool, error) {
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all")
output, err := command.Output()
if err != nil {
return false, fmt.Errorf("inspect build source: %w", err)
}
return len(output) == 0, nil
}

View File

@@ -2,24 +2,29 @@ package build
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "go.maximumdirect.net/releasekit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "go.maximumdirect.net/releasekit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" "go.maximumdirect.net/releasekit/internal/tempdir"
) )
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error { func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
return candidateCallback(spec, output, os.RemoveAll)
}
func candidateCallback(spec *Spec, output io.Writer, remove func(string) error) 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, owner *gotool.Owner) error { return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) (resultErr error) {
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir) moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
if err != nil { if err != nil {
return fmt.Errorf("resolve captured module path: %w", err) return fmt.Errorf("resolve captured module path: %w", err)
@@ -39,7 +44,11 @@ func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candi
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 func() {
if err := remove(temporaryDir); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("remove candidate build directory %q: %w", temporaryDir, err))
}
}()
_, err = BuildResolved(ctx, Request{ _, err = BuildResolved(ctx, Request{
Identity: identity, Identity: identity,
Spec: selected, Spec: selected,

View File

@@ -0,0 +1,124 @@
package build
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"go.maximumdirect.net/releasekit/internal/candidate"
"go.maximumdirect.net/releasekit/internal/releaseidentity"
)
func cleanupFixture(t *testing.T, output string) releaseidentity.Identity {
t.Helper()
repo := t.TempDir()
files := map[string]string{
"go.mod": "module example.com/cleanup\n\ngo 1.25.0\n",
"main.go": fmt.Sprintf("package main\nimport \"fmt\"\nfunc main() { fmt.Println(%q) }\n", output),
}
for name, contents := range files {
if err := os.WriteFile(filepath.Join(repo, name), []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{{"init", "-b", "main"}, {"config", "user.name", "Fixture"}, {"config", "user.email", "fixture@example.invalid"}, {"add", "."}, {"commit", "-m", "fixture"}} {
command := exec.Command("git", args...)
command.Dir = repo
if data, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, data)
}
}
return releaseidentity.Identity{Selection: releaseidentity.Selection{RepositoryDir: repo, ModuleDir: repo}, ModulePath: "example.com/cleanup", Version: "v1.2.3", Tag: "v1.2.3"}
}
type cancelWriter struct{ cancel context.CancelFunc }
func (writer cancelWriter) Write(p []byte) (int, error) {
if strings.Contains(string(p), "checking ") {
writer.cancel()
}
return len(p), nil
}
func TestNativeSmokeCleanupPreservesBuildOutcome(t *testing.T) {
for _, mode := range []string{"success", "smoke error", "cancellation"} {
t.Run(mode, func(t *testing.T) {
output := "fixture v1.2.3"
if mode == "smoke error" {
output = "wrong"
}
identity := cleanupFixture(t, output)
otherArch := "arm64"
if runtime.GOARCH == otherArch {
otherArch = "amd64"
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
request := Request{Identity: identity, OutputDir: t.TempDir(), Spec: Spec{MainPackage: ".", BinaryName: "fixture", Targets: []Target{{OS: runtime.GOOS, Arch: otherArch}}}}
if mode == "cancellation" {
request.Output = cancelWriter{cancel}
}
cleanupErr := errors.New("cleanup failed")
var removed string
result, err := buildResolved(ctx, request, func(path string) error {
removed = path
_ = os.RemoveAll(path)
return cleanupErr
})
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) || len(result.Artifacts) != 1 || result.Artifacts[0].SHA256 == "" || result.CandidateCommit == "" {
t.Fatalf("build = %#v, cleanup = %q, error = %v", result, removed, err)
}
if mode == "success" && !result.SourceVerified {
t.Fatal("completed build lost verified source")
}
if mode == "smoke error" && !strings.Contains(err.Error(), "native version output") {
t.Fatalf("smoke failure missing: %v", err)
}
if mode == "cancellation" && !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation missing: %v", err)
}
})
}
}
func TestCandidateBuildCleanupPreservesOutcome(t *testing.T) {
for _, mode := range []string{"success", "build failure", "cancellation"} {
t.Run(mode, func(t *testing.T) {
identity := cleanupFixture(t, "fixture v1.2.3")
cleanupErr := errors.New("cleanup failed")
var removed string
spec := &Spec{MainPackage: ".", BinaryName: "fixture", Targets: []Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
if mode == "build failure" {
spec.MainPackage = "./missing"
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var output io.Writer
if mode == "cancellation" {
output = cancelWriter{cancel}
}
callback := candidateCallback(spec, output, func(path string) error {
removed = path
_ = os.RemoveAll(path)
return cleanupErr
})
err := callback(ctx, candidate.Candidate{RepositoryDir: identity.RepositoryDir, ModuleDir: identity.ModuleDir, ModulePath: identity.ModulePath, Version: identity.Version, Tag: identity.Tag}, nil)
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) {
t.Fatalf("candidate cleanup = %q, error = %v", removed, err)
}
if mode == "build failure" && !strings.Contains(err.Error(), "build candidate") {
t.Fatalf("build failure missing: %v", err)
}
if mode == "cancellation" && !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation missing: %v", err)
}
})
}
}

View File

@@ -10,12 +10,12 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "go.maximumdirect.net/releasekit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "go.maximumdirect.net/releasekit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "go.maximumdirect.net/releasekit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" "go.maximumdirect.net/releasekit/internal/validation"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "go.maximumdirect.net/releasekit/internal/versioncontract"
) )
var ErrInvalidVersion = errors.New("invalid stable version") var ErrInvalidVersion = errors.New("invalid stable version")
@@ -105,6 +105,9 @@ func Capture(ctx context.Context, request Request) (Candidate, error) {
if err := validateNote(note, request.Product, request.Version); err != nil { if err := validateNote(note, request.Product, request.Version); err != nil {
return captured, fmt.Errorf("release note %q: %w", identity.NotePath, err) return captured, fmt.Errorf("release note %q: %w", identity.NotePath, err)
} }
if err := git.CheckTagRef(ctx, repositoryDir, identity.Tag); err != nil {
return captured, fmt.Errorf("validate local candidate tag: %w", err)
}
if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil { if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil {
return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err) return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err)
} else if exists { } else if exists {
@@ -191,18 +194,18 @@ func inspectLocalState(ctx context.Context, repositoryDir string) (localState, e
if err != nil { if err != nil {
return localState{}, fmt.Errorf("inspect current branch: %w", err) return localState{}, fmt.Errorf("inspect current branch: %w", err)
} }
commitOutput, err := gitOutput(ctx, repositoryDir, "rev-parse", "--verify", "HEAD^{commit}") commit, err := git.CaptureHead(ctx, repositoryDir)
if err != nil { if err != nil {
return localState{}, fmt.Errorf("capture HEAD commit: %w", err) return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
} }
statusOutput, err := gitOutput(ctx, repositoryDir, "status", "--porcelain=v1", "--untracked-files=all") status, err := git.WorktreeStatus(ctx, repositoryDir)
if err != nil { if err != nil {
return localState{}, fmt.Errorf("inspect worktree and index: %w", err) return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
} }
return localState{ return localState{
branch: strings.TrimSpace(string(branchOutput)), branch: strings.TrimSpace(string(branchOutput)),
commit: strings.TrimSpace(string(commitOutput)), commit: commit,
status: strings.TrimSpace(string(statusOutput)), status: status,
}, nil }, nil
} }
@@ -212,6 +215,7 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
err := process.Run(ctx, process.Invocation{ err := process.Run(ctx, process.Invocation{
Args: commandArgs, Args: commandArgs,
Dir: repositoryDir, Dir: repositoryDir,
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
}) })
@@ -230,6 +234,7 @@ func hasLocalTag(ctx context.Context, repositoryDir, tag string) (bool, error) {
err := process.Run(ctx, process.Invocation{ err := process.Run(ctx, process.Invocation{
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag}, Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
Dir: repositoryDir, Dir: repositoryDir,
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
Stderr: &stderr, Stderr: &stderr,
}) })
if err == nil { if err == nil {

View File

@@ -13,8 +13,8 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "go.maximumdirect.net/releasekit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" "go.maximumdirect.net/releasekit/internal/validation"
) )
const testVersion = "v1.2.3" const testVersion = "v1.2.3"

View File

@@ -5,12 +5,11 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
) )
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) { func declaredBuildAssets(result internalbuild.Result) ([]artifact.ExpectedAsset, error) {
settings := result.Settings settings := result.Settings
if len(settings.Targets) == 0 { if len(settings.Targets) == 0 {
if len(result.Artifacts) != 0 { if len(result.Artifacts) != 0 {
@@ -18,47 +17,33 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
} }
return nil, nil return nil, nil
} }
if len(result.Artifacts) != len(settings.Targets) { declarations := make([]artifact.Declaration, 0, len(result.Artifacts))
return nil, fmt.Errorf("build result must contain one artifact for each selected target") for _, built := range result.Artifacts {
declarations = append(declarations, artifact.Declaration{Target: built.Target, Filename: built.Filename})
} }
declarations := artifact.ExpectedBuild(settings.BinaryName, result.Version, settings.Targets) if err := artifact.ValidateDeclarations(settings.BinaryName, result.Version, settings.Targets, declarations); err != nil {
wantByTarget := make(map[artifact.Target]string, len(declarations)) return nil, fmt.Errorf("build result: %w", err)
for _, declaration := range declarations {
target := declaration.Target
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
return nil, fmt.Errorf("build result contains an invalid target")
}
if _, exists := wantByTarget[target]; exists {
return nil, fmt.Errorf("build result repeats target %q", target.OS+"/"+target.Arch)
}
wantByTarget[target] = declaration.Filename
} }
assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1) assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1)
seenTargets := make(map[artifact.Target]bool, len(result.Artifacts)) for _, builtArtifact := range result.Artifacts {
for _, artifact := range result.Artifacts { if filepath.Base(builtArtifact.Path) != builtArtifact.Filename {
wantName, ok := wantByTarget[artifact.Target] return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename)
if !ok || seenTargets[artifact.Target] || artifact.Filename != wantName {
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename)
} }
seenTargets[artifact.Target] = true info, err := os.Stat(builtArtifact.Path)
if filepath.Base(artifact.Path) != artifact.Filename {
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", artifact.Path, artifact.Filename)
}
info, err := os.Stat(artifact.Path)
if err != nil { if err != nil {
return nil, fmt.Errorf("inspect build artifact %q: %w", artifact.Filename, err) return nil, fmt.Errorf("inspect build artifact %q: %w", builtArtifact.Filename, err)
} }
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", builtArtifact.Filename)
} }
assets = append(assets, gitea.ExpectedAsset{ assets = append(assets, artifact.ExpectedAsset{
Filename: artifact.Filename, Filename: builtArtifact.Filename,
SHA256: artifact.SHA256, SHA256: builtArtifact.SHA256,
Size: info.Size(), Size: info.Size(),
Path: artifact.Path, Path: builtArtifact.Path,
}) })
} }
manifest, err := gitea.BuildManifest(assets) manifest, err := artifact.BuildManifest(assets)
if err != nil { if err != nil {
return nil, fmt.Errorf("build checksum manifest: %w", err) return nil, fmt.Errorf("build checksum manifest: %w", err)
} }

View File

@@ -8,9 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
) )
func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) { func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
@@ -38,7 +37,7 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != gitea.ManifestFilename { if len(result) != 3 || result[0].Filename != filepath.Base(firstPath) || result[1].Filename != filepath.Base(secondPath) || result[2].Filename != artifact.ManifestFilename {
t.Fatalf("assets = %#v", result) t.Fatalf("assets = %#v", result)
} }
want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n" want := assetDigest(second) + " app-v1.2.3-darwin-arm64\n" + assetDigest(first) + " app-v1.2.3-linux-amd64\n"

View File

@@ -0,0 +1,102 @@
package delivery
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "go.maximumdirect.net/releasekit/internal/build"
"go.maximumdirect.net/releasekit/internal/forge"
)
func deliveryCleanupFixture(t *testing.T) (string, string) {
t.Helper()
repo := t.TempDir()
files := map[string]string{
"go.mod": "module example.com/delivery\n\ngo 1.25.0\n",
"main.go": "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"fixture v1.2.3\") }\n",
"docs/releases/v1.2.3.md": "# Fixture v1.2.3\n\nNote.\n",
}
for name, contents := range files {
path := filepath.Join(repo, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{{"init", "-b", "main"}, {"config", "user.name", "Fixture"}, {"config", "user.email", "fixture@example.invalid"}, {"add", "."}, {"commit", "-m", "fixture"}, {"tag", "v1.2.3"}} {
command := exec.Command("git", args...)
command.Dir = repo
if data, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, data)
}
}
command := exec.Command("git", "rev-parse", "HEAD")
command.Dir = repo
commit, err := command.Output()
if err != nil {
t.Fatal(err)
}
return repo, strings.TrimSpace(string(commit))
}
type cancelBuildOutput struct{ cancel context.CancelFunc }
func (writer cancelBuildOutput) Write(p []byte) (int, error) {
if strings.Contains(string(p), "building ") {
writer.cancel()
}
return len(p), nil
}
func TestManagedDeliveryCleanupPreservesOutcome(t *testing.T) {
for _, mode := range []string{"complete", "build failure", "cancellation"} {
t.Run(mode, func(t *testing.T) {
repo, commit := deliveryCleanupFixture(t)
note := "# Fixture v1.2.3\n\nNote.\n"
backend := &assetBackend{pageBackend: pageBackend{commit: commit, findResults: []pageFindResult{{release: forge.Release{Tag: "v1.2.3", Body: note, Handle: "release", URL: "https://example.invalid/release"}}}}, lists: []assetListResult{{}}}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
spec := internalbuild.Spec{MainPackage: ".", BinaryName: "fixture", Targets: []artifact.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}
if mode == "build failure" {
spec.MainPackage = "./missing"
}
request := Request{RepositoryDir: repo, Product: "Fixture", Version: "v1.2.3", Backend: backend, Build: &spec}
if mode == "cancellation" {
request.Output = cancelBuildOutput{cancel}
}
cleanupErr := errors.New("cleanup failed")
var removed string
result, err := deliver(ctx, request, func(path string) error {
removed = path
_ = os.RemoveAll(path)
return cleanupErr
})
if !errors.Is(err, cleanupErr) || removed == "" || !strings.Contains(err.Error(), removed) || result.CandidateCommit != commit {
t.Fatalf("delivery = %#v, cleanup = %q, error = %v", result, removed, err)
}
switch mode {
case "complete":
if result.State != Complete || len(result.Uploaded) != 2 || result.ReleaseURL == "" {
t.Fatalf("completed delivery lost observations: %#v", result)
}
case "build failure":
if result.State != NotAttempted || !strings.Contains(err.Error(), "build delivery assets") {
t.Fatalf("build failure lost: %#v, %v", result, err)
}
case "cancellation":
if result.State != NotAttempted || !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation lost: %#v, %v", result, err)
}
}
})
}
}

View File

@@ -9,14 +9,15 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" "go.maximumdirect.net/releasekit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "go.maximumdirect.net/releasekit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "go.maximumdirect.net/releasekit/internal/forge"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "go.maximumdirect.net/releasekit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "go.maximumdirect.net/releasekit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" "go.maximumdirect.net/releasekit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "go.maximumdirect.net/releasekit/internal/tempdir"
"go.maximumdirect.net/releasekit/internal/versioncontract"
) )
type State string type State string
@@ -35,7 +36,7 @@ type Request struct {
Product string Product string
Version string Version string
ExpectedTag string ExpectedTag string
Destination gitea.Destination Backend forge.Backend
Build *internalbuild.Spec Build *internalbuild.Spec
Prebuilt *internalbuild.Result Prebuilt *internalbuild.Result
Output io.Writer Output io.Writer
@@ -49,15 +50,19 @@ type Result struct {
CandidateCommit string CandidateCommit string
ReleaseURL string ReleaseURL string
State State State State
Matching []gitea.AssetIdentity Matching []AssetIdentity
Uploaded []gitea.AssetIdentity Uploaded []AssetIdentity
Pending []gitea.AssetIdentity Pending []AssetIdentity
Conflicting []gitea.AssetIdentity Conflicting []AssetIdentity
Uncertain []gitea.AssetIdentity Uncertain []AssetIdentity
} }
func Deliver(ctx context.Context, request Request) (Result, error) { func Deliver(ctx context.Context, request Request) (Result, error) {
result := Result{State: NotAttempted} return deliver(ctx, request, os.RemoveAll)
}
func deliver(ctx context.Context, request Request, remove func(string) error) (result Result, resultErr error) {
result = Result{State: NotAttempted}
if !versioncontract.ValidStableVersion(request.Version) { if !versioncontract.ValidStableVersion(request.Version) {
return result, candidate.ErrInvalidVersion return result, candidate.ErrInvalidVersion
} }
@@ -67,6 +72,9 @@ 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")
} }
if request.Backend == nil {
return result, errors.New("delivery backend is required")
}
repositoryDir, err := git.RepositoryRoot(ctx, 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)
@@ -111,6 +119,14 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
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", identity.NotePath, err) return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
} }
if request.Prebuilt != nil {
if err := validateBuildIdentity(identity, commit, request.Prebuilt); err != nil {
return result, err
}
}
if err := request.Backend.Preflight(ctx); err != nil {
return result, err
}
build := request.Prebuilt build := request.Prebuilt
if request.Build != nil && len(request.Build.Targets) != 0 { if request.Build != nil && len(request.Build.Targets) != 0 {
@@ -118,7 +134,11 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
if err != nil { if err != nil {
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 func() {
if err := remove(temporaryDir); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("remove delivery build directory %q: %w", temporaryDir, err))
}
}()
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{ built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
Identity: identity, Identity: identity,
Spec: *request.Build, Spec: *request.Build,
@@ -131,7 +151,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
} }
build = &built build = &built
} }
var assets []gitea.ExpectedAsset var assets []artifact.ExpectedAsset
if build != nil { if build != nil {
assets, err = declaredBuildAssets(*build) assets, err = declaredBuildAssets(*build)
if err != nil { if err != nil {
@@ -142,8 +162,12 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
return result, err return result, err
} }
if len(assets) > 0 { if len(assets) > 0 {
if err := gitea.VerifyLocalAssets(assets); err != nil { if err := artifact.VerifyLocalAssets(assets); err != nil {
return result, fmt.Errorf("verify build assets: %w", err) kind := forge.FailureInvalid
if errors.Is(err, artifact.ErrAssetConflict) {
kind = forge.FailureConflict
}
return result, &forge.Failure{Kind: kind, Err: fmt.Errorf("verify build assets: %w", err)}
} }
} }
if build != nil && len(build.Settings.Targets) != 0 { if build != nil && len(build.Settings.Targets) != 0 {
@@ -159,50 +183,61 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
} }
} }
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{ release, pageEvidence, err := coordinateRelease(ctx, request.Backend, identity.Tag, commit, string(note))
Destination: request.Destination,
Tag: identity.Tag,
Commit: commit,
Note: string(note),
})
if err != nil { if err != nil {
result.State = stateForError(err, false) result.State = stateForError(err, false)
return result, err return result, err
} }
result.ReleaseURL = releaseResult.Release.URL result.ReleaseURL = release.URL
if len(assets) == 0 { if len(assets) == 0 {
result.State = Complete result.State = Complete
return result, nil return result, nil
} }
inspection, err := (gitea.Client{}).InspectAssets(ctx, request.Destination, releaseResult.Release.ID, assets) inspection, err := inspectRemoteAssets(ctx, request.Backend, release.Handle, assets)
if err != nil { if err != nil {
result.State = NotAttempted result.State = NotAttempted
if releaseResult.WriteAttempted { if pageEvidence.Attempted {
result.State = Partial result.State = Partial
} }
result.Uncertain = assetIdentities(assets) result.Uncertain = assetIdentities(assets)
return result, err return result, err
} }
uploadResult, uploadErr := (gitea.Client{}).UploadVerifiedMissing(ctx, request.Destination, releaseResult.Release.ID, inspection) uploadResult, uploadErr := uploadMissingAssets(ctx, request.Backend, release.Handle, 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
result.Conflicting = uploadResult.Conflicting result.Conflicting = uploadResult.Conflicting
result.Uncertain = uploadResult.Uncertain result.Uncertain = uploadResult.Uncertain
result.State = State(uploadResult.State) result.State = State(uploadResult.State)
if result.State == NotAttempted && releaseResult.WriteAttempted { if result.State == NotAttempted && pageEvidence.Attempted {
result.State = Partial result.State = Partial
} }
return result, uploadErr return result, uploadErr
} }
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error { func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []artifact.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 err := validateBuildIdentity(identity, commit, build); err != nil {
return err
}
if len(build.Settings.Targets) == 0 {
if len(assets) != 0 {
return errors.New("page-only delivery cannot include binary assets")
}
return nil
}
if len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != artifact.ManifestFilename {
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
}
return nil
}
func validateBuildIdentity(identity releaseidentity.Identity, commit string, build *internalbuild.Result) error {
buildRepository, err := filepath.Abs(build.RepositoryDir) buildRepository, err := filepath.Abs(build.RepositoryDir)
if err != nil { if err != nil {
return fmt.Errorf("resolve build repository: %w", err) return fmt.Errorf("resolve build repository: %w", err)
@@ -215,27 +250,24 @@ func validateBuild(identity releaseidentity.Identity, commit string, build *inte
return errors.New("build result does not identify the selected tagged module") return errors.New("build result does not identify the selected tagged module")
} }
if len(build.Settings.Targets) == 0 { if len(build.Settings.Targets) == 0 {
if len(assets) != 0 {
return errors.New("page-only delivery cannot include binary assets")
}
return nil return nil
} }
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")
} }
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 strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" {
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
} }
func stateForError(err error, pageCreated bool) State { func stateForError(err error, pageCreated bool) State {
var failure *gitea.Failure var failure *forge.Failure
if errors.As(err, &failure) { if errors.As(err, &failure) {
switch failure.Kind { switch failure.Kind {
case gitea.FailureConflict: case forge.FailureConflict:
return Conflict return Conflict
case gitea.FailureUnknown: case forge.FailureUnknown:
return Unknown return Unknown
} }
} }
@@ -245,10 +277,10 @@ func stateForError(err error, pageCreated bool) State {
return NotAttempted return NotAttempted
} }
func assetIdentities(assets []gitea.ExpectedAsset) []gitea.AssetIdentity { func assetIdentities(assets []artifact.ExpectedAsset) []AssetIdentity {
result := make([]gitea.AssetIdentity, 0, len(assets)) result := make([]AssetIdentity, 0, len(assets))
for _, asset := range assets { for _, asset := range assets {
result = append(result, gitea.AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}) result = append(result, AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256})
} }
return result return result
} }

91
internal/delivery/page.go Normal file
View File

@@ -0,0 +1,91 @@
package delivery
import (
"context"
"errors"
"fmt"
"strings"
"go.maximumdirect.net/releasekit/internal/forge"
)
func coordinateRelease(ctx context.Context, backend forge.Backend, tag, commit, note string) (forge.Release, forge.WriteEvidence, error) {
observedCommit, err := backend.TagCommit(ctx, tag)
if err != nil {
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
}
if observedCommit != commit {
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
Kind: forge.FailureConflict,
Err: fmt.Errorf("hosted tag %q targets %q, want %q", tag, observedCommit, commit),
}
}
existing, err := backend.FindRelease(ctx, tag)
if err == nil {
if !matchesExistingRelease(existing, tag, note) {
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
Kind: forge.FailureConflict,
Err: errors.New("existing release does not match the committed note and stable tag"),
}
}
return existing, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, nil
}
if !isForgeFailure(err, forge.FailureNotFound) {
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
}
request := forge.CreateReleaseRequest{
Tag: tag, Commit: commit, Title: releaseNoteTitle(note), Body: note,
}
created, evidence, createErr := backend.CreateRelease(ctx, request)
if createErr == nil && matchesCreatedRelease(created, request) {
return created, evidence, nil
}
if createErr == nil {
createErr = &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("created release does not match the requested stable release")}
}
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain && !isForgeFailure(createErr, forge.FailureMalformed) {
return forge.Release{}, evidence, createErr
}
return reconcileReleaseCreate(ctx, backend, tag, note, evidence, createErr)
}
func reconcileReleaseCreate(ctx context.Context, backend forge.Backend, tag, note string, evidence forge.WriteEvidence, createErr error) (forge.Release, forge.WriteEvidence, error) {
existing, lookupErr := backend.FindRelease(ctx, tag)
if lookupErr == nil {
if matchesExistingRelease(existing, tag, note) {
return existing, evidence, nil
}
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureConflict, Err: errors.Join(createErr, errors.New("reconciled release does not match the committed note and stable tag"))}
}
if isForgeFailure(lookupErr, forge.FailureNotFound) {
if evidence.Disposition == forge.WriteConflict {
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureConflict, Err: createErr}
}
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureUnknown, Err: createErr}
}
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(createErr, lookupErr)}
}
func matchesExistingRelease(release forge.Release, tag, note string) bool {
return release.Tag == tag && release.Body == note && !release.Draft && !release.Prerelease
}
func matchesCreatedRelease(release forge.Release, request forge.CreateReleaseRequest) bool {
return release.Tag == request.Tag && release.Body == request.Body && release.Title == request.Title && !release.Draft && !release.Prerelease
}
func releaseNoteTitle(note string) string {
first := strings.SplitN(note, "\n", 2)[0]
if !strings.HasPrefix(first, "# ") {
return ""
}
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
}
func isForgeFailure(err error, kind forge.FailureKind) bool {
var failure *forge.Failure
return errors.As(err, &failure) && failure.Kind == kind
}

View File

@@ -0,0 +1,204 @@
package delivery
import (
"context"
"errors"
"io"
"strings"
"testing"
"go.maximumdirect.net/releasekit/internal/forge"
)
func TestCoordinateReleaseAcceptsExistingPageAndRetainsItsTitle(t *testing.T) {
backend := &pageBackend{
commit: "commit",
findResults: []pageFindResult{{release: forge.Release{
Tag: "v1.2.3", Title: "Maintained title", Body: "# Release\n\nNotes", Handle: "release",
}}},
}
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nNotes")
if err != nil || release.Title != "Maintained title" || evidence.Attempted {
t.Fatalf("coordinateRelease() = %#v, evidence %#v, error %v", release, evidence, err)
}
if backend.createCalls != 0 || backend.findCalls != 1 {
t.Fatalf("calls = create %d, find %d", backend.createCalls, backend.findCalls)
}
}
func TestCoordinateReleaseRejectsWrongHostedTagAndExistingPage(t *testing.T) {
t.Run("wrong hosted tag", func(t *testing.T) {
backend := &pageBackend{commit: "other"}
_, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
if !isForgeFailure(err, forge.FailureConflict) || evidence.Attempted || backend.findCalls != 0 || backend.createCalls != 0 {
t.Fatalf("coordinateRelease() evidence %#v, error %v, calls find=%d create=%d", evidence, err, backend.findCalls, backend.createCalls)
}
})
t.Run("missing hosted tag", func(t *testing.T) {
backend := &pageBackend{tagErr: &forge.Failure{Kind: forge.FailureNotFound, Err: errors.New("tag missing")}}
_, _, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
if !isForgeFailure(err, forge.FailureNotFound) || backend.findCalls != 0 || backend.createCalls != 0 {
t.Fatalf("coordinateRelease() error = %v, calls find=%d create=%d", err, backend.findCalls, backend.createCalls)
}
})
for _, release := range []forge.Release{
{Tag: "v9.9.9", Body: "# Release", Title: "Release"},
{Tag: "v1.2.3", Body: "different", Title: "Release"},
{Tag: "v1.2.3", Body: "# Release", Title: "Release", Draft: true},
{Tag: "v1.2.3", Body: "# Release", Title: "Release", Prerelease: true},
} {
backend := &pageBackend{commit: "commit", findResults: []pageFindResult{{release: release}}}
_, _, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
if !isForgeFailure(err, forge.FailureConflict) || backend.createCalls != 0 {
t.Fatalf("coordinateRelease(%#v) error = %v, create calls = %d", release, err, backend.createCalls)
}
}
}
func TestCoordinateReleaseCreatesWithFullNoteAndVerifiesRequestedTitle(t *testing.T) {
backend := &pageBackend{
commit: "commit",
findResults: []pageFindResult{{err: &forge.Failure{Kind: forge.FailureNotFound}}},
createResult: pageCreateResult{
release: forge.Release{Tag: "v1.2.3", Title: "Release", Body: "# Release\n\nFull note", Handle: "release"},
evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted},
},
}
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nFull note")
if err != nil || release.Handle != "release" || evidence.Disposition != forge.WriteAccepted || !evidence.Attempted {
t.Fatalf("coordinateRelease() = %#v, evidence %#v, error %v", release, evidence, err)
}
if got := backend.createRequest; got.Tag != "v1.2.3" || got.Commit != "commit" || got.Title != "Release" || got.Body != "# Release\n\nFull note" || got.Draft || got.Prerelease {
t.Fatalf("CreateRelease request = %#v", got)
}
}
func TestCoordinateReleaseReconcilesCreateOutcomes(t *testing.T) {
createErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("connection lost")}
lookupErr := errors.New("lookup unavailable")
matching := forge.Release{Tag: "v1.2.3", Title: "Original", Body: "# Release\n\nNotes", Handle: "release"}
wrong := forge.Release{Tag: "v1.2.3", Body: "different", Handle: "other"}
notFound := &forge.Failure{Kind: forge.FailureNotFound}
for _, test := range []struct {
name string
create pageCreateResult
followup pageFindResult
wantKind forge.FailureKind
wantRecovered bool
wantCause error
}{
{
name: "matching page after conflict",
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteConflict}},
followup: pageFindResult{release: matching}, wantRecovered: true,
},
{
name: "different page after uncertain write",
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
followup: pageFindResult{release: wrong}, wantKind: forge.FailureConflict,
},
{
name: "missing after conflict",
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteConflict}},
followup: pageFindResult{err: notFound}, wantKind: forge.FailureConflict,
},
{
name: "missing after uncertain write",
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
followup: pageFindResult{err: notFound}, wantKind: forge.FailureUnknown, wantCause: createErr,
},
{
name: "unavailable follow-up",
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
followup: pageFindResult{err: lookupErr}, wantKind: forge.FailureUnknown, wantCause: lookupErr,
},
{
name: "inconsistent accepted response reconciled",
create: pageCreateResult{release: wrong, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
followup: pageFindResult{release: matching}, wantRecovered: true,
},
{
name: "inconsistent accepted response missing",
create: pageCreateResult{release: wrong, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
followup: pageFindResult{err: notFound}, wantKind: forge.FailureUnknown,
},
} {
t.Run(test.name, func(t *testing.T) {
backend := &pageBackend{
commit: "commit",
findResults: []pageFindResult{{err: notFound}, test.followup},
createResult: test.create,
}
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nNotes")
if test.wantRecovered {
if err != nil || release.Handle != "release" || !evidence.Attempted || backend.findCalls != 2 {
t.Fatalf("recovered result = %#v, evidence %#v, error %v, find calls %d", release, evidence, err, backend.findCalls)
}
return
}
if !isForgeFailure(err, test.wantKind) || !evidence.Attempted {
t.Fatalf("result evidence %#v, error %v; want kind %s", evidence, err, test.wantKind)
}
if test.wantCause != nil && !errors.Is(err, test.wantCause) {
t.Fatalf("error %v does not retain cause %v", err, test.wantCause)
}
})
}
}
type pageFindResult struct {
release forge.Release
err error
}
type pageCreateResult struct {
release forge.Release
evidence forge.WriteEvidence
err error
}
type pageBackend struct {
commit string
tagErr error
findResults []pageFindResult
findCalls int
createResult pageCreateResult
createCalls int
createRequest forge.CreateReleaseRequest
}
func (backend *pageBackend) Preflight(context.Context) error { return nil }
func (backend *pageBackend) TagCommit(context.Context, string) (string, error) {
return backend.commit, backend.tagErr
}
func (backend *pageBackend) FindRelease(context.Context, string) (forge.Release, error) {
index := backend.findCalls
backend.findCalls++
if index >= len(backend.findResults) {
return forge.Release{}, &forge.Failure{Kind: forge.FailureNotFound}
}
result := backend.findResults[index]
return result.release, result.err
}
func (backend *pageBackend) CreateRelease(_ context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) {
backend.createCalls++
backend.createRequest = request
return backend.createResult.release, backend.createResult.evidence, backend.createResult.err
}
func (*pageBackend) ListAssets(context.Context, any) ([]forge.Asset, error) { return nil, nil }
func (*pageBackend) OpenAsset(context.Context, any) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("")), nil
}
func (*pageBackend) UploadAsset(context.Context, any, string, io.Reader) (forge.WriteEvidence, error) {
return forge.WriteEvidence{}, nil
}

View File

@@ -8,9 +8,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "go.maximumdirect.net/releasekit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "go.maximumdirect.net/releasekit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
) )
const performanceArtifactSize = 16 << 20 const performanceArtifactSize = 16 << 20
@@ -45,7 +44,7 @@ func BenchmarkPerformanceAssetPreflight(b *testing.B) {
if err != nil { if err != nil {
b.Fatal(err) b.Fatal(err)
} }
if err := gitea.VerifyLocalAssets(assets); err != nil { if err := artifact.VerifyLocalAssets(assets); err != nil {
b.Fatal(err) b.Fatal(err)
} }
} }

View File

@@ -0,0 +1,279 @@
package delivery
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"sort"
"go.maximumdirect.net/releasekit/internal/artifact"
"go.maximumdirect.net/releasekit/internal/forge"
)
type AssetState string
const (
AssetMatching AssetState = "matching"
AssetMissing AssetState = "missing"
AssetConflicting AssetState = "conflicting"
AssetUnverifiable AssetState = "unverifiable"
)
type AssetIdentity struct {
Filename string
SHA256 string
}
type AssetInspection struct {
Expected artifact.ExpectedAsset
State AssetState
Existing []forge.Asset
Err error
}
type Inspection struct {
Assets []AssetInspection
Unrelated []forge.Asset
}
type UploadResult struct {
State State
Matching []AssetIdentity
Uploaded []AssetIdentity
Pending []AssetIdentity
Conflicting []AssetIdentity
Uncertain []AssetIdentity
}
func inspectRemoteAssets(ctx context.Context, backend forge.Backend, releaseHandle any, expected []artifact.ExpectedAsset) (Inspection, error) {
if err := artifact.ValidateExpectedAssets(expected); err != nil {
return Inspection{}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
}
listed, err := backend.ListAssets(ctx, releaseHandle)
if err != nil {
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
for _, item := range expected {
inspection.Assets = append(inspection.Assets, AssetInspection{Expected: item, State: AssetUnverifiable, Err: err})
}
return inspection, err
}
byName := make(map[string][]forge.Asset, len(listed))
for _, item := range listed {
byName[item.Filename] = append(byName[item.Filename], item)
}
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
declared := make(map[string]struct{}, len(expected))
for _, item := range expected {
declared[item.Filename] = struct{}{}
matches := byName[item.Filename]
observation := AssetInspection{Expected: item, Existing: matches}
switch len(matches) {
case 0:
observation.State = AssetMissing
case 1:
digest, sizeMatches, err := remoteAssetDigest(ctx, backend, matches[0].Handle, item.Size)
if err != nil {
observation.State = AssetUnverifiable
observation.Err = err
} else if sizeMatches && digest == item.SHA256 {
observation.State = AssetMatching
} else {
observation.State = AssetConflicting
}
default:
observation.State = AssetConflicting
}
inspection.Assets = append(inspection.Assets, observation)
}
for _, item := range listed {
if _, ok := declared[item.Filename]; !ok {
inspection.Unrelated = append(inspection.Unrelated, item)
}
}
return inspection, nil
}
func remoteAssetDigest(ctx context.Context, backend forge.Backend, handle any, expectedSize int64) (string, bool, error) {
if err := ctx.Err(); err != nil {
return "", false, err
}
stream, err := backend.OpenAsset(ctx, handle)
if err != nil {
return "", false, err
}
hasher := sha256.New()
read, readErr := io.Copy(hasher, io.LimitReader(stream, expectedSize))
excess := false
if readErr == nil && read == expectedSize {
var probe [1]byte
for attempts := 0; ; attempts++ {
var n int
n, readErr = stream.Read(probe[:])
excess = n != 0
if readErr != nil || excess {
if readErr == io.EOF {
readErr = nil
}
break
}
if attempts == 99 {
readErr = io.ErrNoProgress
break
}
}
}
closeErr := stream.Close()
if err := errors.Join(readErr, closeErr, ctx.Err()); err != nil {
return "", false, &forge.Failure{Kind: forge.FailureUnverifiable, Err: err}
}
return hex.EncodeToString(hasher.Sum(nil)), read == expectedSize && !excess, nil
}
func uploadMissingAssets(ctx context.Context, backend forge.Backend, releaseHandle any, inspection Inspection) (result UploadResult, err error) {
expected := make([]artifact.ExpectedAsset, len(inspection.Assets))
for index, observation := range inspection.Assets {
expected[index] = observation.Expected
}
if err := artifact.ValidateExpectedAssets(expected); err != nil {
return UploadResult{State: Partial}, &forge.Failure{Kind: forge.FailureInvalid, Err: err}
}
result = UploadResult{State: Partial}
preflightConflict := false
preflightUnverifiable := false
var inspectionErrors []error
var missing []artifact.ExpectedAsset
pending := make(map[string]struct{}, len(expected))
// Keep the initial observation order and compact pending identities once,
// including on early returns after partial or unresolved uploads.
defer func() {
remaining := result.Pending[:0]
for _, item := range result.Pending {
if _, ok := pending[item.Filename]; ok {
remaining = append(remaining, item)
}
}
result.Pending = remaining
}()
for _, observation := range inspection.Assets {
item := identity(observation.Expected)
switch observation.State {
case AssetMatching:
result.Matching = append(result.Matching, item)
case AssetMissing:
result.Pending = append(result.Pending, item)
pending[observation.Expected.Filename] = struct{}{}
missing = append(missing, observation.Expected)
case AssetConflicting:
preflightConflict = true
result.Conflicting = append(result.Conflicting, item)
case AssetUnverifiable:
preflightUnverifiable = true
result.Uncertain = append(result.Uncertain, item)
if observation.Err != nil {
inspectionErrors = append(inspectionErrors, fmt.Errorf("verify existing attachment %q: %w", item.Filename, observation.Err))
}
default:
return result, &forge.Failure{Kind: forge.FailureInvalid, Err: fmt.Errorf("asset %q has no inspection result", observation.Expected.Filename)}
}
}
if preflightConflict {
result.State = Conflict
return result, &forge.Failure{Kind: forge.FailureConflict, Err: errors.Join(errors.New("one or more existing attachments conflict"), errors.Join(inspectionErrors...))}
}
if preflightUnverifiable {
result.State = NotAttempted
return result, &forge.Failure{Kind: forge.FailureUnverifiable, Err: errors.Join(errors.New("one or more existing attachments could not be verified"), errors.Join(inspectionErrors...))}
}
if len(pending) == 0 {
result.State = Complete
return result, nil
}
orderMissing(missing)
for _, item := range missing {
content, err := assetContent(item)
if err != nil {
return result, &forge.Failure{Kind: forge.FailureInvalid, Err: fmt.Errorf("open local release asset %q: %w", item.Filename, err)}
}
evidence, uploadErr := backend.UploadAsset(ctx, releaseHandle, item.Filename, content)
closeErr := content.Close()
if uploadErr == nil {
result.Uploaded = append(result.Uploaded, identity(item))
delete(pending, item.Filename)
continue
}
operationErr := errors.Join(uploadErr, closeErr)
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain {
return result, operationErr
}
delete(pending, item.Filename)
if err := reconcileAssetUpload(ctx, backend, releaseHandle, item, evidence, operationErr, &result); err != nil {
return result, err
}
}
if len(pending) == 0 {
result.State = Complete
}
return result, nil
}
func reconcileAssetUpload(ctx context.Context, backend forge.Backend, releaseHandle any, item artifact.ExpectedAsset, evidence forge.WriteEvidence, uploadErr error, result *UploadResult) error {
inspection, inspectErr := inspectRemoteAssets(ctx, backend, releaseHandle, []artifact.ExpectedAsset{item})
if inspectErr != nil || len(inspection.Assets) != 1 {
result.State = Unknown
result.Uncertain = append(result.Uncertain, identity(item))
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, inspectErr)}
}
observation := inspection.Assets[0]
switch observation.State {
case AssetMatching:
result.Matching = append(result.Matching, identity(item))
return nil
case AssetConflicting:
result.State = Conflict
result.Conflicting = append(result.Conflicting, identity(item))
return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr}
case AssetMissing:
if evidence.Disposition == forge.WriteConflict {
result.State = Conflict
result.Conflicting = append(result.Conflicting, identity(item))
return &forge.Failure{Kind: forge.FailureConflict, Err: uploadErr}
}
result.State = Unknown
result.Uncertain = append(result.Uncertain, identity(item))
return &forge.Failure{Kind: forge.FailureUnknown, Err: uploadErr}
default:
result.State = Unknown
result.Uncertain = append(result.Uncertain, identity(item))
return &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(uploadErr, observation.Err)}
}
}
func orderMissing(missing []artifact.ExpectedAsset) {
sort.Slice(missing, func(i, j int) bool {
if missing[i].Filename == artifact.ManifestFilename {
return false
}
if missing[j].Filename == artifact.ManifestFilename {
return true
}
return missing[i].Filename < missing[j].Filename
})
}
func assetContent(asset artifact.ExpectedAsset) (io.ReadCloser, error) {
if len(asset.Content) > 0 {
return io.NopCloser(bytes.NewReader(asset.Content)), nil
}
return os.Open(asset.Path)
}
func identity(asset artifact.ExpectedAsset) AssetIdentity {
return AssetIdentity{Filename: asset.Filename, SHA256: asset.SHA256}
}

View File

@@ -0,0 +1,543 @@
package delivery
import (
"bytes"
"context"
"errors"
"io"
"os"
"reflect"
"strings"
"testing"
"go.maximumdirect.net/releasekit/internal/artifact"
"go.maximumdirect.net/releasekit/internal/forge"
)
func TestInspectRemoteAssetsComparesContentAndPreservesUnrelatedAssets(t *testing.T) {
backend := &assetBackend{
lists: []assetListResult{{assets: []forge.Asset{
{Filename: "same.bin", Size: 4, Handle: "same"},
{Filename: "different.bin", Size: 4, Handle: "different"},
{Filename: "duplicate.bin", Size: 4, Handle: "first"},
{Filename: "duplicate.bin", Size: 4, Handle: "second"},
{Filename: "unrelated.txt", Size: 3, Handle: "unrelated"},
}}},
content: map[string][]byte{
"same": []byte("same"),
"different": []byte("diff"),
},
}
expected := []artifact.ExpectedAsset{
expectedAsset("same.bin", []byte("same")),
expectedAsset("different.bin", []byte("else")),
expectedAsset("duplicate.bin", []byte("dupe")),
expectedAsset("missing.bin", []byte("gone")),
}
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", expected)
if err != nil {
t.Fatal(err)
}
wantStates := []AssetState{AssetMatching, AssetConflicting, AssetConflicting, AssetMissing}
for i, observation := range inspection.Assets {
if observation.State != wantStates[i] {
t.Errorf("asset %q state = %s, want %s", observation.Expected.Filename, observation.State, wantStates[i])
}
}
if len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "unrelated.txt" {
t.Fatalf("unrelated assets = %#v", inspection.Unrelated)
}
if len(backend.opened) != 2 || !backend.opened[0].closed || !backend.opened[1].closed {
t.Fatalf("opened asset streams = %#v", backend.opened)
}
}
type controlledAssetStream struct {
*bytes.Reader
readErr error
errorAfter int
closeErr error
cancel context.CancelFunc
read int
closed bool
}
func (stream *controlledAssetStream) Read(p []byte) (int, error) {
n, err := stream.Reader.Read(p)
stream.read += n
if stream.cancel != nil {
stream.cancel()
stream.cancel = nil
}
if stream.readErr != nil && n > 0 && stream.read > stream.errorAfter {
err, stream.readErr = stream.readErr, nil
}
return n, err
}
func (stream *controlledAssetStream) Close() error {
stream.closed = true
return stream.closeErr
}
type controlledAssetBackend struct {
pageBackend
stream *controlledAssetStream
}
func (backend *controlledAssetBackend) ListAssets(context.Context, any) ([]forge.Asset, error) {
return []forge.Asset{{Filename: "asset.bin", Size: 999, Handle: "asset"}}, nil
}
func (backend *controlledAssetBackend) OpenAsset(context.Context, any) (io.ReadCloser, error) {
return backend.stream, nil
}
func TestInspectRemoteAssetBoundsContentAndPreservesErrors(t *testing.T) {
readErr := errors.New("read failed")
closeErr := errors.New("close failed")
for _, test := range []struct {
name, expected, actual string
readErr, closeErr error
cancel bool
errorAfter int
state AssetState
}{
{"empty", "", "", nil, nil, false, 0, AssetMatching},
{"shorter", "abcd", "abc", nil, nil, false, 0, AssetConflicting},
{"matching", "abcd", "abcd", nil, nil, false, 0, AssetMatching},
{"different", "abcd", "wxyz", nil, nil, false, 0, AssetConflicting},
{"oversized", "abcd", "abcdefghijk", nil, nil, false, 0, AssetConflicting},
{"empty oversized", "", "abc", nil, nil, false, 0, AssetConflicting},
{"data and error", "abcd", "abcd", readErr, nil, false, 0, AssetUnverifiable},
{"probe data and error", "abcd", "abcde", readErr, nil, false, 4, AssetUnverifiable},
{"close failure", "abcd", "abcdefgh", nil, closeErr, false, 0, AssetUnverifiable},
{"cancellation", "abcd", "abcd", nil, nil, true, 0, AssetUnverifiable},
} {
t.Run(test.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
stream := &controlledAssetStream{Reader: bytes.NewReader([]byte(test.actual)), readErr: test.readErr, errorAfter: test.errorAfter, closeErr: test.closeErr}
if test.cancel {
stream.cancel = cancel
}
backend := &controlledAssetBackend{stream: stream}
inspection, err := inspectRemoteAssets(ctx, backend, "release", []artifact.ExpectedAsset{expectedAsset("asset.bin", []byte(test.expected))})
if err != nil || len(inspection.Assets) != 1 || inspection.Assets[0].State != test.state || !stream.closed || stream.read > len(test.expected)+1 {
t.Fatalf("inspection = %#v, error = %v, bytes = %d, closed = %t", inspection, err, stream.read, stream.closed)
}
cause := inspection.Assets[0].Err
if test.readErr != nil && !errors.Is(cause, test.readErr) || test.closeErr != nil && !errors.Is(cause, test.closeErr) || test.cancel && !errors.Is(cause, context.Canceled) {
t.Fatalf("inspection lost cause: %v", cause)
}
})
}
}
func TestInspectRemoteAssetsListingFailureDoesNotBecomeUnknownWrite(t *testing.T) {
assets := []artifact.ExpectedAsset{expectedAsset("a.bin", []byte("a")), expectedAsset("b.bin", []byte("b"))}
listErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("service unavailable")}
backend := &assetBackend{lists: []assetListResult{{err: listErr}}}
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", assets)
if !errors.Is(err, listErr) || len(inspection.Assets) != len(assets) {
t.Fatalf("inspection = %#v, error %v", inspection, err)
}
for _, observation := range inspection.Assets {
if observation.State != AssetUnverifiable || !errors.Is(observation.Err, listErr) {
t.Fatalf("listing failure observation = %#v", observation)
}
}
result, err := uploadMissingAssets(context.Background(), backend, "release", inspection)
if !isForgeFailure(err, forge.FailureUnverifiable) || result.State != NotAttempted || len(result.Uncertain) != len(assets) || len(backend.uploadNames) != 0 {
t.Fatalf("uploadMissingAssets() = %#v, error %v, uploads %v", result, err, backend.uploadNames)
}
}
func TestInspectRemoteAssetsKeepsMatchingManifestWhenBinariesAreMissing(t *testing.T) {
binary := expectedAsset("app-linux-amd64", []byte("binary"))
manifest, err := artifact.BuildManifest([]artifact.ExpectedAsset{binary})
if err != nil {
t.Fatal(err)
}
backend := &assetBackend{
lists: []assetListResult{{assets: []forge.Asset{{Filename: manifest.Filename, Size: int64(len(manifest.Content)), Handle: "manifest"}}}},
content: map[string][]byte{"manifest": manifest.Content},
}
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", []artifact.ExpectedAsset{binary, manifest})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetMissing || inspection.Assets[1].State != AssetMatching {
t.Fatalf("manifest inspection = %#v", inspection.Assets)
}
result, err := uploadMissingAssets(context.Background(), backend, "release", inspection)
if err != nil || result.State != Complete || !reflect.DeepEqual(result.Uploaded, []AssetIdentity{identity(binary)}) {
t.Fatalf("uploadMissingAssets() = %#v, error %v", result, err)
}
if !reflect.DeepEqual(backend.uploadNames, []string{binary.Filename}) {
t.Fatalf("uploaded names = %v", backend.uploadNames)
}
}
func TestInspectRemoteAssetsRejectsSubsetOrDifferentManifestContent(t *testing.T) {
binaryA := expectedAsset("a", []byte("a"))
binaryB := expectedAsset("b", []byte("b"))
manifest, err := artifact.BuildManifest([]artifact.ExpectedAsset{binaryA, binaryB})
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
content []byte
}{
{name: "subset", content: []byte("a a\n")},
{name: "different", content: []byte("different manifest\n")},
} {
t.Run(test.name, func(t *testing.T) {
backend := &assetBackend{
lists: []assetListResult{{assets: []forge.Asset{{Filename: artifact.ManifestFilename, Handle: "manifest"}}}},
content: map[string][]byte{"manifest": test.content},
}
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", []artifact.ExpectedAsset{binaryA, binaryB, manifest})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[2].State != AssetConflicting {
t.Fatalf("manifest state = %s, want conflicting", inspection.Assets[2].State)
}
})
}
}
func TestUploadMissingAssetsPreflightsAndOrdersWrites(t *testing.T) {
assets := []artifact.ExpectedAsset{
expectedAsset("z.bin", []byte("z")),
expectedAsset(artifact.ManifestFilename, []byte("manifest")),
expectedAsset("a.bin", []byte("a")),
}
inspection := inspectionFor(assets, map[string]AssetState{
"z.bin": AssetMissing,
artifact.ManifestFilename: AssetMissing,
"a.bin": AssetMissing,
})
backend := &assetBackend{uploads: []assetUploadResult{
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
}}
result, err := uploadMissingAssets(context.Background(), backend, "release", inspection)
if err != nil || result.State != Complete {
t.Fatalf("uploadMissingAssets() = %#v, error %v", result, err)
}
want := []string{"a.bin", "z.bin", artifact.ManifestFilename}
if !reflect.DeepEqual(backend.uploadNames, want) {
t.Fatalf("upload order = %v, want %v", backend.uploadNames, want)
}
}
func TestUploadMissingAssetsStopsBeforeWritesForConflictOrUnverifiableContent(t *testing.T) {
for _, test := range []struct {
name string
state AssetState
want State
}{
{name: "conflict", state: AssetConflicting, want: Conflict},
{name: "unverifiable", state: AssetUnverifiable, want: NotAttempted},
} {
t.Run(test.name, func(t *testing.T) {
assets := []artifact.ExpectedAsset{expectedAsset("existing", []byte("old")), expectedAsset("missing", []byte("new"))}
inspection := inspectionFor(assets, map[string]AssetState{"existing": test.state, "missing": AssetMissing})
result, err := uploadMissingAssets(context.Background(), &assetBackend{}, "release", inspection)
if err == nil || result.State != test.want || len(result.Pending) != 1 || len(result.Conflicting)+len(result.Uncertain) != 1 {
t.Fatalf("uploadMissingAssets() = %#v, error %v", result, err)
}
})
}
}
func TestUploadMissingAssetsPreservesInspectionCausesAndConflictPrecedence(t *testing.T) {
readErr := errors.New("asset download failed")
for _, conflict := range []bool{false, true} {
name := "unverifiable"
if conflict {
name = "conflict"
}
t.Run(name, func(t *testing.T) {
backend := &assetBackend{}
inspection := Inspection{Assets: []AssetInspection{
{Expected: expectedAsset("canceled.bin", []byte("a")), State: AssetUnverifiable, Err: context.Canceled},
{Expected: expectedAsset("unreadable.bin", []byte("b")), State: AssetUnverifiable, Err: readErr},
}}
wantState, wantKind := NotAttempted, forge.FailureUnverifiable
if conflict {
inspection.Assets = append(inspection.Assets, AssetInspection{Expected: expectedAsset("conflicting.bin", []byte("c")), State: AssetConflicting})
wantState, wantKind = Conflict, forge.FailureConflict
}
result, err := uploadMissingAssets(context.Background(), backend, "release", inspection)
if !isForgeFailure(err, wantKind) || result.State != wantState || len(result.Uncertain) != 2 || len(backend.uploadNames) != 0 {
t.Fatalf("result = %#v, error %v", result, err)
}
if !errors.Is(err, context.Canceled) || !errors.Is(err, readErr) || !strings.Contains(err.Error(), "canceled.bin") || !strings.Contains(err.Error(), "unreadable.bin") {
t.Fatalf("aggregate lost cause or asset context: %v", err)
}
})
}
}
func TestUploadMissingAssetsReconcilesOnceAndKeepsLaterAssetsPending(t *testing.T) {
assets := []artifact.ExpectedAsset{
expectedAsset("a.bin", []byte("a")),
expectedAsset("b.bin", []byte("b")),
expectedAsset("c.bin", []byte("c")),
}
for _, test := range []struct {
name string
disposition forge.WriteDisposition
followupAssets []forge.Asset
followupData map[string][]byte
openErrors map[string]error
followupErr error
wantState State
wantCategory string
wantContinue bool
}{
{
name: "matching bytes resolve uncertain upload", disposition: forge.WriteUncertain,
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("a")},
wantState: Complete, wantCategory: "matching", wantContinue: true,
},
{
name: "different bytes conflict", disposition: forge.WriteUncertain,
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("x")},
wantState: Conflict, wantCategory: "conflicting",
},
{
name: "oversized bytes conflict", disposition: forge.WriteUncertain,
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, followupData: map[string][]byte{"a": []byte("oversized")},
wantState: Conflict, wantCategory: "conflicting",
},
{
name: "missing after conflict remains conflict", disposition: forge.WriteConflict,
wantState: Conflict, wantCategory: "conflicting",
},
{
name: "missing after uncertain upload is unknown", disposition: forge.WriteUncertain,
wantState: Unknown, wantCategory: "uncertain",
},
{
name: "unreadable follow-up is unknown", disposition: forge.WriteUncertain,
followupAssets: []forge.Asset{{Filename: "a.bin", Handle: "a"}}, openErrors: map[string]error{"a": errors.New("download failed")},
wantState: Unknown, wantCategory: "uncertain",
},
} {
t.Run(test.name, func(t *testing.T) {
first := assetListResult{assets: []forge.Asset{}}
followup := assetListResult{assets: test.followupAssets, err: test.followupErr}
backend := &assetBackend{
lists: []assetListResult{first, followup},
content: test.followupData,
openErrors: test.openErrors,
uploads: []assetUploadResult{{
evidence: forge.WriteEvidence{Attempted: true, Disposition: test.disposition},
err: &forge.Failure{Kind: failureKindForDisposition(test.disposition), Err: errors.New("upload outcome unresolved")},
}},
}
inspection, err := inspectRemoteAssets(context.Background(), backend, "release", assets)
if err != nil {
t.Fatal(err)
}
result, uploadErr := uploadMissingAssets(context.Background(), backend, "release", inspection)
if result.State != test.wantState || backend.listCalls != 2 {
t.Fatalf("result = %#v, error %v, list calls %d, uploads %v", result, uploadErr, backend.listCalls, backend.uploadNames)
}
if test.wantContinue {
if uploadErr != nil || !reflect.DeepEqual(backend.uploadNames, []string{"a.bin", "b.bin", "c.bin"}) || len(result.Pending) != 0 {
t.Fatalf("continued result = %#v, error %v, uploads %v", result, uploadErr, backend.uploadNames)
}
} else {
if uploadErr == nil || !reflect.DeepEqual(backend.uploadNames, []string{"a.bin"}) {
t.Fatalf("stopped result = %#v, error %v, uploads %v", result, uploadErr, backend.uploadNames)
}
if !reflect.DeepEqual(result.Pending, []AssetIdentity{identity(assets[1]), identity(assets[2])}) {
t.Fatalf("pending = %#v", result.Pending)
}
}
var category []AssetIdentity
switch test.wantCategory {
case "matching":
category = result.Matching
case "conflicting":
category = result.Conflicting
case "uncertain":
category = result.Uncertain
}
if len(category) != 1 || category[0].Filename != "a.bin" {
t.Fatalf("%s assets = %#v", test.wantCategory, category)
}
})
}
}
func TestUploadMissingAssetsPreservesAcknowledgedProgressOnDefinitiveFailure(t *testing.T) {
assets := []artifact.ExpectedAsset{expectedAsset("a.bin", []byte("a")), expectedAsset("b.bin", []byte("b")), expectedAsset("c.bin", []byte("c"))}
backend := &assetBackend{uploads: []assetUploadResult{
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteRejected}, err: &forge.Failure{Kind: forge.FailureRejected, Err: errors.New("denied")}},
}}
result, err := uploadMissingAssets(context.Background(), backend, "release", inspectionFor(assets, map[string]AssetState{
"a.bin": AssetMissing, "b.bin": AssetMissing, "c.bin": AssetMissing,
}))
if err == nil || result.State != Partial || !reflect.DeepEqual(result.Uploaded, []AssetIdentity{identity(assets[0])}) || !reflect.DeepEqual(result.Pending, []AssetIdentity{identity(assets[1]), identity(assets[2])}) {
t.Fatalf("uploadMissingAssets() = %#v, error %v", result, err)
}
}
func TestUploadMissingAssetsRetainsPendingObservationOrderOnEarlyReturn(t *testing.T) {
assets := []artifact.ExpectedAsset{
expectedAsset("z.bin", []byte("z")),
expectedAsset("a.bin", []byte("a")),
expectedAsset("c.bin", []byte("c")),
expectedAsset("b.bin", []byte("b")),
}
for _, disposition := range []forge.WriteDisposition{forge.WriteRejected, forge.WriteUncertain} {
t.Run(string(disposition), func(t *testing.T) {
backend := &assetBackend{uploads: []assetUploadResult{
{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
{evidence: forge.WriteEvidence{Attempted: true, Disposition: disposition}, err: errors.New("upload stopped")},
}}
result, err := uploadMissingAssets(context.Background(), backend, "release", inspectionFor(assets, map[string]AssetState{
"z.bin": AssetMissing, "a.bin": AssetMissing, "c.bin": AssetMissing, "b.bin": AssetMissing,
}))
wantPending := []AssetIdentity{identity(assets[0]), identity(assets[2]), identity(assets[3])}
wantState := Partial
if disposition == forge.WriteUncertain {
wantPending = wantPending[:2]
wantState = Unknown
if !reflect.DeepEqual(result.Uncertain, []AssetIdentity{identity(assets[3])}) {
t.Fatalf("uncertain = %#v", result.Uncertain)
}
}
if err == nil || result.State != wantState || !reflect.DeepEqual(result.Pending, wantPending) || !reflect.DeepEqual(result.Uploaded, []AssetIdentity{identity(assets[1])}) {
t.Fatalf("result = %#v, error %v; want pending %#v", result, err, wantPending)
}
})
}
}
func TestUploadMissingAssetsBorrowsReaderAndClosesItAfterCall(t *testing.T) {
filePath := t.TempDir() + "/asset.bin"
if err := os.WriteFile(filePath, []byte("asset"), 0o600); err != nil {
t.Fatal(err)
}
asset := artifact.ExpectedAsset{Filename: "asset.bin", SHA256: assetDigest([]byte("asset")), Size: 5, Path: filePath}
backend := &assetBackend{uploads: []assetUploadResult{{evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}}}}
result, err := uploadMissingAssets(context.Background(), backend, "release", inspectionFor([]artifact.ExpectedAsset{asset}, map[string]AssetState{"asset.bin": AssetMissing}))
if err != nil || result.State != Complete || !backend.readerWasOpen {
t.Fatalf("uploadMissingAssets() = %#v, error %v, reader open during call %v", result, err, backend.readerWasOpen)
}
if backend.borrowed == nil {
t.Fatal("backend did not receive the file reader")
}
if _, err := backend.borrowed.Stat(); !errors.Is(err, os.ErrClosed) {
t.Fatalf("borrowed file stat error = %v, want closed file", err)
}
}
type assetListResult struct {
assets []forge.Asset
err error
}
type assetUploadResult struct {
evidence forge.WriteEvidence
err error
}
type assetBackend struct {
pageBackend
lists []assetListResult
listCalls int
content map[string][]byte
openErrors map[string]error
opened []*trackedAssetStream
uploads []assetUploadResult
uploadNames []string
uploadContent [][]byte
borrowed *os.File
readerWasOpen bool
}
func (backend *assetBackend) ListAssets(context.Context, any) ([]forge.Asset, error) {
index := backend.listCalls
backend.listCalls++
if index >= len(backend.lists) {
return nil, errors.New("unexpected asset listing")
}
result := backend.lists[index]
return result.assets, result.err
}
func (backend *assetBackend) OpenAsset(_ context.Context, handle any) (io.ReadCloser, error) {
key, ok := handle.(string)
if !ok {
return nil, errors.New("unexpected asset handle")
}
if err := backend.openErrors[key]; err != nil {
return nil, err
}
stream := &trackedAssetStream{Reader: bytes.NewReader(backend.content[key])}
backend.opened = append(backend.opened, stream)
return stream, nil
}
func (backend *assetBackend) UploadAsset(_ context.Context, _ any, filename string, content io.Reader) (forge.WriteEvidence, error) {
backend.uploadNames = append(backend.uploadNames, filename)
if file, ok := content.(*os.File); ok {
backend.borrowed = file
}
data, err := io.ReadAll(content)
backend.uploadContent = append(backend.uploadContent, data)
if backend.borrowed != nil {
_, statErr := backend.borrowed.Stat()
backend.readerWasOpen = statErr == nil
}
index := len(backend.uploadNames) - 1
if err != nil {
return forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}, err
}
if index >= len(backend.uploads) {
return forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}, nil
}
result := backend.uploads[index]
return result.evidence, result.err
}
type trackedAssetStream struct {
*bytes.Reader
closed bool
}
func (stream *trackedAssetStream) Close() error {
stream.closed = true
return nil
}
func expectedAsset(filename string, content []byte) artifact.ExpectedAsset {
return artifact.ExpectedAsset{Filename: filename, SHA256: assetDigest(content), Size: int64(len(content)), Content: content}
}
func inspectionFor(assets []artifact.ExpectedAsset, states map[string]AssetState) Inspection {
inspection := Inspection{Assets: make([]AssetInspection, 0, len(assets))}
for _, item := range assets {
inspection.Assets = append(inspection.Assets, AssetInspection{Expected: item, State: states[item.Filename]})
}
return inspection
}
func failureKindForDisposition(disposition forge.WriteDisposition) forge.FailureKind {
if disposition == forge.WriteConflict {
return forge.FailureConflict
}
return forge.FailureTransport
}

153
internal/forge/forge.go Normal file
View File

@@ -0,0 +1,153 @@
// Package forge defines the backend-neutral operations used by hosted delivery.
package forge
import (
"context"
"io"
)
// Backend provides the hosted operations needed by the shared delivery
// workflow. Implementations own destination-specific rules and protocol
// details; the shared workflow uses only the logical values and evidence in
// this package.
type Backend interface {
// Preflight verifies credentials and access to the configured destination
// using read-only operations. It does not depend on a source tag or release.
Preflight(ctx context.Context) error
// TagCommit returns the commit observed for the complete tag.
TagCommit(ctx context.Context, tag string) (string, error)
// FindRelease returns the release for the complete tag. A missing release
// is reported with FailureNotFound.
FindRelease(ctx context.Context, tag string) (Release, error)
// CreateRelease creates the requested release and returns the protocol
// evidence even when an error occurs.
CreateRelease(ctx context.Context, request CreateReleaseRequest) (Release, WriteEvidence, error)
// ListAssets returns the assets attached to the release identified by handle.
ListAssets(ctx context.Context, releaseHandle any) ([]Asset, error)
// OpenAsset opens the remote asset identified by handle. The caller owns and
// must close the returned stream, including when reading it fails.
OpenAsset(ctx context.Context, assetHandle any) (io.ReadCloser, error)
// UploadAsset uploads content to the release identified by handle. The
// backend borrows content for the duration of this call and must finish any
// request or multipart work before returning; it must not close content.
UploadAsset(ctx context.Context, releaseHandle any, filename string, content io.Reader) (WriteEvidence, error)
}
// Release contains the logical release properties used by shared delivery.
// Handle is opaque to shared code and is interpreted only by the backend that
// produced it.
type Release struct {
URL string
Tag string
Title string
Body string
Draft bool
Prerelease bool
Handle any
}
// CreateReleaseRequest contains the release properties requested by shared
// delivery.
type CreateReleaseRequest struct {
Tag string
Commit string
Title string
Body string
Draft bool
Prerelease bool
}
// Asset contains the remote properties needed to compare an attachment with a
// declared local asset. Handle is opaque to shared code and is interpreted only
// by the backend that produced it.
type Asset struct {
Filename string
Size int64
Handle any
}
// PreflightFailureKind classifies a failure to verify destination settings,
// credentials, or repository access.
type PreflightFailureKind string
const (
PreflightInvalidDestination PreflightFailureKind = "invalid_destination"
PreflightMissingCredential PreflightFailureKind = "missing_credential"
PreflightInvalidCredential PreflightFailureKind = "invalid_credential"
PreflightInsufficientPermissions PreflightFailureKind = "insufficient_permissions"
PreflightInaccessibleDestination PreflightFailureKind = "inaccessible_destination"
PreflightUnavailable PreflightFailureKind = "unavailable"
)
// PreflightFailure reports a classified credential or destination access
// failure. Err retains the underlying cause for diagnostics and cancellation
// identity.
type PreflightFailure struct {
Kind PreflightFailureKind
Err error
}
func (f *PreflightFailure) Error() string {
if f.Err != nil {
return string(f.Kind) + ": " + f.Err.Error()
}
return string(f.Kind)
}
// Unwrap returns the cause of the preflight failure, if any.
func (f *PreflightFailure) Unwrap() error { return f.Err }
// FailureKind classifies a backend-neutral delivery operation failure.
type FailureKind string
const (
FailureInvalid FailureKind = "invalid_request"
FailureNotFound FailureKind = "not_found"
FailureUnauthorized FailureKind = "unauthorized"
FailureConflict FailureKind = "conflict"
FailureMalformed FailureKind = "malformed_response"
FailureUnverifiable FailureKind = "unverifiable"
FailureTransport FailureKind = "transport"
FailureRejected FailureKind = "rejected"
FailureUnknown FailureKind = "unknown"
)
// Failure reports a backend-neutral operation failure. Backend-specific
// status and operation details belong in Err. Err is retained for errors.Is
// and errors.As inspection.
type Failure struct {
Kind FailureKind
Err error
}
func (f *Failure) Error() string {
if f.Err != nil {
return string(f.Kind) + ": " + f.Err.Error()
}
return string(f.Kind)
}
// Unwrap returns the cause of the operation failure, if any.
func (f *Failure) Unwrap() error { return f.Err }
// WriteDisposition describes the backend's evidence about a write operation.
type WriteDisposition string
const (
WriteNotAttempted WriteDisposition = "not_attempted"
WriteAccepted WriteDisposition = "accepted"
WriteRejected WriteDisposition = "rejected"
WriteConflict WriteDisposition = "conflict"
WriteUncertain WriteDisposition = "uncertain"
)
// WriteEvidence records whether a write was handed to the transport and what
// its acknowledgment establishes. Accepted means the backend received and
// decoded the required protocol acknowledgment; shared content checks still
// apply. Uncertain means the outcome could not be established. Attempted marks
// handoff to the transport and does not claim the server received the request.
// Evidence remains meaningful when the operation also returns an error.
type WriteEvidence struct {
Attempted bool
Disposition WriteDisposition
}

View File

@@ -12,7 +12,7 @@ import (
"strings" "strings"
"sync" "sync"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "go.maximumdirect.net/releasekit/internal/process"
) )
var errBlobObjectMissing = errors.New("Git object is missing") var errBlobObjectMissing = errors.New("Git object is missing")
@@ -51,6 +51,9 @@ func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, er
reader.mu.Lock() reader.mu.Lock()
defer reader.mu.Unlock() defer reader.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
}
if reader.terminal != nil { if reader.terminal != nil {
return nil, reader.terminal return nil, reader.terminal
} }
@@ -128,8 +131,9 @@ func (reader *BlobReader) startLocked() error {
Args: []string{"git", "cat-file", "--batch"}, Args: []string{"git", "cat-file", "--batch"},
Dir: reader.repositoryDir, Dir: reader.repositoryDir,
Env: map[string]string{ Env: map[string]string{
"GIT_NO_LAZY_FETCH": "1", "GIT_NO_LAZY_FETCH": "1",
"GIT_ALLOW_PROTOCOL": "", "GIT_ALLOW_PROTOCOL": "",
"GIT_NO_REPLACE_OBJECTS": "1",
}, },
Stderr: &reader.stderr, Stderr: &reader.stderr,
}) })

View File

@@ -10,6 +10,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -156,6 +157,46 @@ exec sleep 60`)
} }
} }
func TestBlobReaderCanceledQueuedReadKeepsSessionUsable(t *testing.T) {
fixture := newRemoteFixture(t)
object := gitText(t, fixture.work, "rev-parse", "HEAD:source.txt")
reader := NewBlobReader(t.Context(), fixture.work)
defer reader.Close()
if body, err := reader.Read(t.Context(), object); err != nil || string(body) != "candidate" {
t.Fatalf("first read = %q, %v", body, err)
}
reader.mu.Lock()
ctx, cancel := context.WithCancel(t.Context())
observed := make(chan struct{})
queued := &observedContext{Context: ctx, observed: observed}
result := make(chan error, 1)
go func() {
_, err := reader.Read(queued, object)
result <- err
}()
<-observed
cancel()
reader.mu.Unlock()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatalf("queued read error = %v", err)
}
if body, err := reader.Read(t.Context(), object); err != nil || string(body) != "candidate" {
t.Fatalf("later read = %q, %v", body, err)
}
}
type observedContext struct {
context.Context
once sync.Once
observed chan struct{}
}
func (ctx *observedContext) Err() error {
err := ctx.Context.Err()
ctx.once.Do(func() { close(ctx.observed) })
return err
}
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) { func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
installFakeGit(t, `printf 'fixture Git failure\n' >&2 installFakeGit(t, `printf 'fixture Git failure\n' >&2
exit 23`) exit 23`)

View File

@@ -0,0 +1,215 @@
package git
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDiscoverRepositoryRoot(t *testing.T) {
repo := newRemoteFixture(t)
child := filepath.Join(repo.work, "child")
if err := os.Mkdir(child, 0755); err != nil {
t.Fatal(err)
}
alias := filepath.Join(t.TempDir(), "alias")
if err := os.Symlink(repo.work, alias); err != nil {
t.Fatal(err)
}
worktree := filepath.Join(t.TempDir(), "worktree")
gitRun(t, repo.work, "worktree", "add", "--detach", worktree)
for _, tc := range []struct{ dir, want string }{{repo.work, repo.work}, {child, repo.work}, {alias, repo.work}, {worktree, worktree}} {
root, found, err := DiscoverRepositoryRoot(context.Background(), tc.dir)
want, e := filepath.EvalSymlinks(tc.want)
if e != nil {
t.Fatal(e)
}
if err != nil || !found || root != want {
t.Fatalf("Discover(%s) = %q, %t, %v; want %q", tc.dir, root, found, err, want)
}
}
standalone := t.TempDir()
if root, found, err := DiscoverRepositoryRoot(context.Background(), standalone); err != nil || found || root != "" {
t.Fatalf("standalone = %q, %t, %v", root, found, err)
}
if _, err := RepositoryRoot(context.Background(), standalone); err == nil {
t.Fatal("strict lookup accepted standalone")
}
if _, found, err := DiscoverRepositoryRoot(context.Background(), repo.bare); err == nil || found {
t.Fatalf("bare repository: found %t, error %v", found, err)
}
broken := t.TempDir()
if err := os.WriteFile(filepath.Join(broken, ".git"), []byte("gitdir: missing\n"), 0600); err != nil {
t.Fatal(err)
}
if _, found, err := DiscoverRepositoryRoot(context.Background(), broken); err == nil || found {
t.Fatalf("broken marker: found %t, error %v", found, err)
}
if _, found, err := DiscoverRepositoryRoot(context.Background(), filepath.Join(standalone, "missing")); err == nil || found {
t.Fatalf("missing directory: found %t, error %v", found, err)
}
}
func TestNoRepositoryBoundary(t *testing.T) {
for _, tc := range []struct {
message string
want bool
}{
{"fatal: not a git repository (or any of the parent directories): .git\n", true},
{"fatal: not a git repository (or any parent up to mount point /some path)\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).\n", true},
{"fatal: not a git repository: '/bad'", false},
{"fatal: detected dubious ownership in repository", false},
} {
if _, got := noRepositoryBoundary(tc.message); got != tc.want {
t.Errorf("diagnostic %q = %t", tc.message, got)
}
}
}
func TestDiscoverRepositoryRootHonorsDiscoveryBoundaries(t *testing.T) {
repo := newRemoteFixture(t)
child := filepath.Join(repo.work, "child")
if err := os.Mkdir(child, 0o755); err != nil {
t.Fatal(err)
}
alias := filepath.Join(t.TempDir(), "alias")
if err := os.Symlink(repo.work, alias); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(repo.work, ".git", "HEAD")); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, directory, ceiling string
standalone bool
}{
{"damaged ancestor", child, "", false},
{"excluded ancestor", child, repo.work, true},
{"physical ceiling alias", child, alias, true},
{"literal ceiling alias", child, string(os.PathListSeparator) + alias, false},
{"ceiling includes current directory", repo.work, repo.work, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("GIT_CEILING_DIRECTORIES", tc.ceiling)
_, found, err := DiscoverRepositoryRoot(t.Context(), tc.directory)
if tc.standalone {
if err != nil || found {
t.Fatalf("excluded checkout: found %t, error %v", found, err)
}
} else {
var exitErr *exec.ExitError
if found || !errors.As(err, &exitErr) || exitErr.ExitCode() != 128 {
t.Fatalf("damaged checkout: found %t, error %v", found, err)
}
}
})
}
bin := t.TempDir()
script := "#!/bin/sh\nprintf 'fatal: not a git repository (or any parent up to mount point %s)\\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).\\n' \"$DISCOVERY_BOUNDARY\" >&2\nexit 128\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
t.Setenv("GIT_CEILING_DIRECTORIES", "")
t.Setenv("DISCOVERY_BOUNDARY", repo.work)
if _, found, err := DiscoverRepositoryRoot(t.Context(), child); err != nil || found {
t.Fatalf("filesystem boundary: found %t, error %v", found, err)
}
}
func TestDiscoverRepositoryRootFailures(t *testing.T) {
dir := t.TempDir()
bin := t.TempDir()
script := "#!/bin/sh\nprintf '%s\\n' 'fatal: detected dubious ownership in repository' >&2\nexit 128\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
_, found, err := DiscoverRepositoryRoot(context.Background(), dir)
var exitErr *exec.ExitError
if found || !errors.As(err, &exitErr) || exitErr.ExitCode() != 128 || !strings.Contains(err.Error(), "dubious ownership") {
t.Fatalf("found %t, error %v", found, err)
}
if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\nprintf '%s\\n' 'fatal: permission denied' >&2\nexit 128\n"), 0o755); err != nil {
t.Fatal(err)
}
_, found, err = DiscoverRepositoryRoot(context.Background(), dir)
if found || !errors.As(err, &exitErr) || exitErr.ExitCode() != 128 || !strings.Contains(err.Error(), "permission denied") {
t.Fatalf("found %t, error %v", found, err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, _, err := DiscoverRepositoryRoot(ctx, dir); !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation = %v", err)
}
}
func TestDiscoverRepositoryRootInterruptedGit(t *testing.T) {
dir := t.TempDir()
bin := t.TempDir()
ready := filepath.Join(t.TempDir(), "ready")
script := "#!/bin/sh\n[ \"$LC_ALL\" = C ] || exit 42\n: > \"$DISCOVERY_READY\"\nexec /bin/sleep 60\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
t.Setenv("DISCOVERY_READY", ready)
t.Setenv("LC_ALL", "different-locale")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { _, _, err := DiscoverRepositoryRoot(ctx, dir); done <- err }()
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if _, err := os.Stat(ready); err == nil {
break
}
select {
case err := <-done:
t.Fatalf("Git ended before readiness: %v", err)
case <-timer.C:
cancel()
err := <-done
t.Fatalf("Git did not become ready: %v", err)
case <-ticker.C:
}
}
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation = %v", err)
}
if got := os.Getenv("LC_ALL"); got != "different-locale" {
t.Fatalf("parent locale changed to %q", got)
}
}
func TestDiscoverRepositoryRootWithoutGit(t *testing.T) {
repo := newRemoteFixture(t)
standalone := t.TempDir()
t.Setenv("PATH", t.TempDir())
if _, found, err := DiscoverRepositoryRoot(context.Background(), standalone); found || err != nil {
t.Fatalf("standalone: found %t, error %v", found, err)
}
if _, found, err := DiscoverRepositoryRoot(context.Background(), repo.work); found || !errors.Is(err, exec.ErrNotFound) {
t.Fatalf("checkout: found %t, error %v", found, err)
}
broken := t.TempDir()
if err := os.WriteFile(filepath.Join(broken, ".git"), []byte("broken"), 0600); err != nil {
t.Fatal(err)
}
if _, found, err := DiscoverRepositoryRoot(context.Background(), broken); found || !errors.Is(err, exec.ErrNotFound) {
t.Fatalf("broken marker: found %t, error %v", found, err)
}
t.Setenv("GIT_DIR", "invalid")
if _, found, err := DiscoverRepositoryRoot(context.Background(), standalone); found || !errors.Is(err, exec.ErrNotFound) {
t.Fatalf("explicit context: found %t, error %v", found, err)
}
}

43
internal/git/display.go Normal file
View File

@@ -0,0 +1,43 @@
package git
import (
"net/url"
"regexp"
"strings"
)
// Match userinfo through @ before treating quotes as diagnostic delimiters.
var httpURLSpan = regexp.MustCompile(`(?i)https?://(?:[^\s/?#<>]*@)?[^\s"'<>]*`)
// DisplayURL hides HTTP userinfo without changing the endpoint used by Git.
func DisplayURL(raw string) string {
lower := strings.ToLower(raw)
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return raw
}
authority := raw[strings.Index(raw, "://")+3:]
if end := strings.IndexAny(authority, "/?#"); end >= 0 {
authority = authority[:end]
}
if !strings.Contains(authority, "@") {
return raw
}
parsed, err := url.Parse(raw)
if err != nil || parsed.User == nil || parsed.Host == "" {
return "[redacted URL]"
}
parsed.User = url.User("redacted")
return parsed.String()
}
func displayDiagnostic(message string) string {
return httpURLSpan.ReplaceAllStringFunc(message, DisplayURL)
}
// DisplayDiagnostic redacts recognized HTTP credentials in a display string.
func DisplayDiagnostic(message string) string { return displayDiagnostic(message) }
type displayError struct{ cause error }
func (err displayError) Error() string { return displayDiagnostic(err.cause.Error()) }
func (err displayError) Unwrap() error { return err.cause }

View File

@@ -0,0 +1,103 @@
package git
import (
"context"
"errors"
"os/exec"
"strings"
"testing"
)
func TestDisplayURL(t *testing.T) {
for _, test := range []struct{ input, want string }{
{"https://user:password@example.test/repo", "https://redacted@example.test/repo"},
{"https://token@example.test/repo", "https://redacted@example.test/repo"},
{"https://user:secret'part@example.test/repo", "https://redacted@example.test/repo"},
{"https://user%40example:secret%2Fvalue@example.test/repo", "https://redacted@example.test/repo"},
{"http://broken%zz:secret@example.test/repo", "[redacted URL]"},
{"https://example.test/repo", "https://example.test/repo"},
{"git@example.test:owner/repo", "git@example.test:owner/repo"},
} {
if got := DisplayURL(test.input); got != test.want {
t.Errorf("DisplayURL(%q) = %q, want %q", test.input, got, test.want)
}
}
}
func TestDisplayDiagnosticRedactsUserinfoBeforeQuotedContext(t *testing.T) {
for _, test := range []struct{ input, want string }{
{"fatal: 'https://user:secret'part@example.test/repo': denied", "fatal: 'https://redacted@example.test/repo': denied"},
{"failed at https://token'part@example.test", "failed at https://redacted@example.test"},
{"fatal: 'https://user:secret%27part@example.test/repo': denied", "fatal: 'https://redacted@example.test/repo': denied"},
{"fatal: 'https://broken%zz:secret'part@example.test/repo': denied", "fatal: '[redacted URL]': denied"},
{"fatal: 'https://token'part@': denied", "fatal: '[redacted URL]': denied"},
{"see 'https://example.test/path' and git@example.test:repo", "see 'https://example.test/path' and git@example.test:repo"},
} {
if got := displayDiagnostic(test.input); got != test.want {
t.Errorf("displayDiagnostic(%q) = %q, want %q", test.input, got, test.want)
}
}
}
func TestResolveRedactsCredentialsInNativeGitErrors(t *testing.T) {
fixture := newRemoteFixture(t)
for _, remote := range []string{"https://user:secret'part@example.test/repo", "https://user:secret%27part@example.test/repo"} {
_, err := Resolve(t.Context(), fixture.work, remote)
var exit *exec.ExitError
if !errors.As(err, &exit) || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "user:") || !strings.Contains(err.Error(), "example.test/repo") {
t.Fatalf("unsafe or incomplete native Git diagnostic: %v", err)
}
}
}
func TestGitFailuresRedactCredentialsAndPreserveCause(t *testing.T) {
installFakeGit(t, `case "$*" in
*check-ref-format*) exit 0 ;;
esac
printf '%s\n' "fatal: unable to access 'https://user:secret@example.test/repo': https://token@example.test/path" >&2
exit 23`)
endpoint := Endpoint{PushURL: "https://user:secret@example.test/repo", ScopedName: "fixture"}
for _, operation := range []struct {
name string
run func() error
}{
{"query", func() error { _, err := endpoint.Query(context.Background(), t.TempDir(), "v1.2.3"); return err }},
{"push", func() error {
return endpoint.PushTag(context.Background(), t.TempDir(), "v1.2.3", strings.Repeat("a", 40))
}},
} {
t.Run(operation.name, func(t *testing.T) {
err := operation.run()
var exit *exec.ExitError
if !errors.As(err, &exit) || exit.ExitCode() != 23 {
t.Fatalf("error = %v, want Git exit 23", err)
}
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "token") || strings.Contains(err.Error(), "user") || !strings.Contains(err.Error(), "example.test/repo") {
t.Fatalf("unsafe or unhelpful diagnostic: %v", err)
}
})
}
}
func TestDisplayErrorPreservesWrappedCause(t *testing.T) {
for _, userinfo := range []string{"token", "token'part", "token%27part"} {
cause := errors.New("failed at https://" + userinfo + "@example.test/path")
err := displayError{cause: cause}
if !errors.Is(err, cause) || strings.Contains(err.Error(), "token") {
t.Fatalf("sanitized error = %v", err)
}
}
}
func TestEndpointChangeRedactsNewDestination(t *testing.T) {
fixture := newRemoteFixture(t)
endpoint, err := Resolve(t.Context(), fixture.work, "origin")
if err != nil {
t.Fatal(err)
}
gitRun(t, fixture.work, "remote", "set-url", "--push", "origin", "https://new-token@example.test/repo")
err = endpoint.Recheck(t.Context(), fixture.work)
if !errors.Is(err, ErrEndpointChanged) || strings.Contains(err.Error(), "new-token") || !strings.Contains(err.Error(), "example.test/repo") {
t.Fatalf("endpoint change error = %v", err)
}
}

View File

@@ -9,7 +9,7 @@ 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, tag, remote, expectedCommit string) error { func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expectedCommit string) error {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil { if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return err return err
} }
if !validObjectID(expectedCommit) { if !validObjectID(expectedCommit) {
@@ -17,7 +17,7 @@ func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expe
} }
ref := "refs/tags/" + 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", tag, remote, err) return fmt.Errorf("fetch tag %s from remote %s: %w", tag, DisplayURL(remote), err)
} }
source, err := InspectTaggedSource(ctx, repositoryDir, tag) source, err := InspectTaggedSource(ctx, repositoryDir, tag)
if err != nil { if err != nil {
@@ -35,7 +35,7 @@ func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expe
// VerifyTaggedCheckout confirms that the existing local tag, HEAD, and clean // VerifyTaggedCheckout confirms that the existing local tag, HEAD, and clean
// worktree all identify the expected commit. It does not fetch or check out. // worktree all identify the expected commit. It does not fetch or check out.
func VerifyTaggedCheckout(ctx context.Context, repositoryDir, tag, expectedCommit string) error { func VerifyTaggedCheckout(ctx context.Context, repositoryDir, tag, expectedCommit string) error {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil { if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return err return err
} }
if !validObjectID(expectedCommit) { if !validObjectID(expectedCommit) {

View File

@@ -9,17 +9,18 @@ import (
"fmt" "fmt"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "go.maximumdirect.net/releasekit/internal/process"
) )
var ( var (
ErrInvalidRemote = errors.New("invalid Git remote") ErrInvalidRemote = errors.New("invalid Git remote")
ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs") ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs")
ErrEndpointChanged = errors.New("Git publication endpoint changed") ErrEndpointChanged = errors.New("Git publication endpoint changed")
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch") ErrTransportConfiguration = errors.New("Git transport configuration is unreadable")
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit") ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote") ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable") ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable")
) )
// Endpoint identifies the single effective publication destination captured // Endpoint identifies the single effective publication destination captured
@@ -43,7 +44,7 @@ const (
) )
// RemoteRef holds an exact ref's object and optional peeled commit. State is // RemoteRef holds an exact ref's object and optional peeled commit. State is
// RefConflict only when more than one exact result or malformed data is found. // RefConflict when records are inconsistent or incomplete.
type RemoteRef struct { type RemoteRef struct {
State RefState State RefState
Object string Object string
@@ -60,14 +61,14 @@ type Snapshot struct {
// command-scoped remote whose read and push URLs resolve to that same endpoint. // command-scoped remote whose read and push URLs resolve to that same endpoint.
func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) { func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, error) {
if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") { if strings.TrimSpace(remoteName) == "" || strings.HasPrefix(remoteName, "-") {
return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, remoteName) return Endpoint{}, fmt.Errorf("%w: %q", ErrInvalidRemote, DisplayURL(remoteName))
} }
urls, err := pushURLs(ctx, repositoryDir, remoteName) urls, err := pushURLs(ctx, repositoryDir, remoteName)
if err != nil { if err != nil {
return Endpoint{}, err return Endpoint{}, err
} }
if len(urls) != 1 { if len(urls) != 1 {
return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, remoteName, len(urls)) return Endpoint{}, fmt.Errorf("%w: %s resolves to %d distinct destinations", ErrMultiplePushURLs, DisplayURL(remoteName), len(urls))
} }
scopedName, err := newScopedName() scopedName, err := newScopedName()
if err != nil { if err != nil {
@@ -84,15 +85,56 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e
return endpoint, nil return endpoint, nil
} }
// ObserveEndpoint creates a read-only command-scoped endpoint from previously
// captured publication evidence. The recorded URL is used for both directions;
// current transport settings are copied only when the original remote exists.
func ObserveEndpoint(ctx context.Context, repositoryDir, endpointURL, remoteName string) (Endpoint, error) {
if endpointURL == "" {
return Endpoint{}, fmt.Errorf("empty captured endpoint")
}
scopedName, err := newScopedName()
if err != nil {
return Endpoint{}, fmt.Errorf("create command-scoped remote name: %w", err)
}
endpoint := Endpoint{RemoteName: remoteName, PushURL: endpointURL, ScopedName: scopedName}
if remoteName != "" {
config, configErr := transportConfig(ctx, repositoryDir, remoteName, scopedName)
if configErr != nil {
return Endpoint{}, configErr
}
endpoint.transport = config
}
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
return Endpoint{}, err
}
return endpoint, nil
}
// EqualObjectID compares supported full object IDs without regard to hex case.
func EqualObjectID(left, right string) bool {
return validObjectID(left) && validObjectID(right) && strings.EqualFold(left, right)
}
// ValidObjectID reports whether value is a full supported Git object ID.
func ValidObjectID(value string) bool { return validObjectID(value) }
// DisplayError exposes credential-safe rendering for shared recovery errors.
func DisplayError(err error) error {
if err == nil {
return nil
}
return displayError{cause: err}
}
// Recheck confirms both the selected remote's effective push destination and // Recheck confirms both the selected remote's effective push destination and
// the scoped remote's effective read and push URLs still identify this endpoint. // the scoped remote's effective read and push URLs still identify this endpoint.
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error { func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName) urls, err := pushURLs(ctx, repositoryDir, endpoint.RemoteName)
if err != nil { if err != nil {
return fmt.Errorf("re-resolve remote %q: %w", endpoint.RemoteName, err) return fmt.Errorf("re-resolve remote %q: %w", DisplayURL(endpoint.RemoteName), err)
} }
if len(urls) != 1 || urls[0] != endpoint.PushURL { if len(urls) != 1 || urls[0] != endpoint.PushURL {
return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, endpoint.RemoteName, strings.Join(urls, ", ")) return fmt.Errorf("%w: remote %q now resolves to %q", ErrEndpointChanged, DisplayURL(endpoint.RemoteName), displayDiagnostic(strings.Join(urls, ", ")))
} }
return endpoint.verifyScopedURLs(ctx, repositoryDir) return endpoint.verifyScopedURLs(ctx, repositoryDir)
} }
@@ -100,7 +142,7 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro
// Query reads exact main and 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, tag string) (Snapshot, error) { func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil { if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return Snapshot{}, err return Snapshot{}, err
} }
mainRef := "refs/heads/main" mainRef := "refs/heads/main"
@@ -109,7 +151,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
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 {
snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}} snapshot := Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}
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, DisplayURL(endpoint.PushURL), err)
} }
main := RemoteRef{State: RefAbsent} main := RemoteRef{State: RefAbsent}
remoteTag := RemoteRef{State: RefAbsent} remoteTag := RemoteRef{State: RefAbsent}
@@ -119,7 +161,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
} }
object, name, ok := strings.Cut(line, "\t") object, name, ok := strings.Cut(line, "\t")
if !ok || !validObjectID(object) { if !ok || !validObjectID(object) {
return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line) return Snapshot{Main: RemoteRef{State: RefUnreadable}, Tag: RemoteRef{State: RefUnreadable}}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
} }
switch name { switch name {
case mainRef: case mainRef:
@@ -130,7 +172,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (
remoteTag = mergeRef(remoteTag, object, true) remoteTag = mergeRef(remoteTag, object, true)
} }
} }
return Snapshot{Main: main, Tag: remoteTag}, nil return Snapshot{Main: main, Tag: finalizeRef(remoteTag)}, nil
} }
// RequireCandidate requires exact main equality and an absent remote // RequireCandidate requires exact main equality and an absent remote
@@ -161,11 +203,11 @@ func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, ta
func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error { func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error {
readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName) readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName)
if err != nil { if err != nil {
return fmt.Errorf("resolve scoped read URL: %w", err) return fmt.Errorf("%w: resolve scoped read URL: %w", ErrTransportConfiguration, err)
} }
resolved := outputLines(readURL) resolved := outputLines(readURL)
if len(resolved) != 1 || resolved[0] != endpoint.PushURL { if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, resolved, endpoint.PushURL) return fmt.Errorf("%w: scoped URL resolves to %q, expected %q", ErrEndpointChanged, displayDiagnostic(strings.Join(resolved, ", ")), DisplayURL(endpoint.PushURL))
} }
// The scoped remote sets its push URL explicitly to the same captured value. // The scoped remote sets its push URL explicitly to the same captured value.
// Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf // Git applies the same insteadOf rules to both explicit URLs; pushInsteadOf
@@ -197,7 +239,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
continue continue
} }
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", remoteName, key, err) return nil, fmt.Errorf("%w: read transport setting remote.%s.%s: %w", ErrTransportConfiguration, DisplayURL(remoteName), key, err)
} }
for _, value := range outputLines(output) { for _, value := range outputLines(output) {
config = append(config, "remote."+scopedName+"."+key+"="+value) config = append(config, "remote."+scopedName+"."+key+"="+value)
@@ -209,7 +251,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) { func pushURLs(ctx context.Context, repositoryDir, remoteName string) ([]string, error) {
output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName) output, err := run(ctx, repositoryDir, nil, "remote", "get-url", "--push", "--all", remoteName)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve push URL for remote %q: %w", remoteName, err) return nil, fmt.Errorf("resolve push URL for remote %q: %w", DisplayURL(remoteName), err)
} }
values := outputLines(output) values := outputLines(output)
unique := make([]string, 0, len(values)) unique := make([]string, 0, len(values))
@@ -234,13 +276,15 @@ func run(ctx context.Context, dir string, config []string, args ...string) (stri
err := process.Run(ctx, process.Invocation{ err := process.Run(ctx, process.Invocation{
Args: append([]string{"git"}, commandArgs...), Args: append([]string{"git"}, commandArgs...),
Dir: dir, Dir: dir,
Env: map[string]string{"GIT_NO_REPLACE_OBJECTS": "1"},
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,
}) })
if err != nil { if err != nil {
err = displayError{cause: err}
message := strings.TrimSpace(stderr.String()) message := strings.TrimSpace(stderr.String())
if message != "" { if message != "" {
return stdout.String(), fmt.Errorf("%w: %s", err, message) return stdout.String(), fmt.Errorf("%w: %s", err, displayDiagnostic(message))
} }
return stdout.String(), err return stdout.String(), err
} }
@@ -294,16 +338,42 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
return ref return ref
} }
func checkTagRef(ctx context.Context, repositoryDir, tag string) error { func finalizeRef(ref RemoteRef) RemoteRef {
if tag == "" { if ref.State == RefPresent && ref.Object == "" && ref.Peeled != "" {
ref.State = RefConflict
}
return ref
}
// CheckTagRef validates a complete tag name using Git ref rules.
func CheckTagRef(ctx context.Context, repositoryDir, tag string) error {
valid, err := ValidateTagRef(ctx, repositoryDir, tag)
if err != nil {
return err
}
if !valid {
return fmt.Errorf("invalid Git tag name %q", tag) return fmt.Errorf("invalid Git tag name %q", tag)
} }
if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
}
return nil return nil
} }
// ValidateTagRef distinguishes a rejected ref from failures to run Git or
// access the selected repository.
func ValidateTagRef(ctx context.Context, repositoryDir, tag string) (bool, error) {
if tag == "" {
return false, nil
}
_, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag)
if err == nil {
return true, nil
}
var exitErr interface{ ExitCode() int }
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("check Git tag name: %w", err)
}
func validObjectID(object string) bool { func validObjectID(object string) bool {
if len(object) != 40 && len(object) != 64 { if len(object) != 40 && len(object) != 64 {
return false return false

View File

@@ -42,6 +42,46 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) {
} }
} }
func TestObserveEndpointUsesCapturedURLAndCurrentNamedTransportSettings(t *testing.T) {
repo := newRemoteFixture(t)
gitRun(t, repo.work, "remote", "remove", "origin")
gitRun(t, repo.work, "config", "remote.renamed.receivepack", "git-receive-pack")
endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "renamed")
if err != nil {
t.Fatal(err)
}
if endpoint.PushURL != repo.bare || len(endpoint.transport) != 1 || endpoint.transport[0] != "remote."+endpoint.ScopedName+".receivepack=git-receive-pack" {
t.Fatalf("observed endpoint = %#v", endpoint)
}
ref, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3")
if err != nil || ref.State != RefAbsent {
t.Fatalf("QueryTag() = %#v, %v", ref, err)
}
}
func TestObserveEndpointDoesNotSelectOriginWithoutAName(t *testing.T) {
repo := newRemoteFixture(t)
gitRun(t, repo.work, "config", "remote.origin.receivepack", "custom-receive-pack")
endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "")
if err != nil {
t.Fatal(err)
}
if len(endpoint.transport) != 0 {
t.Fatalf("transport settings = %v; unnamed endpoint must not select origin", endpoint.transport)
}
}
func TestObserveEndpointRejectsCapturedURLRewriting(t *testing.T) {
repo := newRemoteFixture(t)
replacement := filepath.Join(t.TempDir(), "replacement.git")
gitRun(t, repo.work, "init", "--bare", replacement)
gitRun(t, repo.work, "config", "url."+replacement+".insteadOf", repo.bare)
_, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "")
if !errors.Is(err, ErrEndpointChanged) {
t.Fatalf("ObserveEndpoint() error = %v, want ErrEndpointChanged", err)
}
}
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) { func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
fixture := newRemoteFixture(t) fixture := newRemoteFixture(t)
endpoint, err := Resolve(context.Background(), fixture.work, "origin") endpoint, err := Resolve(context.Background(), fixture.work, "origin")
@@ -184,6 +224,52 @@ func TestMergeRefMarksDuplicateExactResultsConflicting(t *testing.T) {
} }
} }
func TestTagQueryFinalizesCompleteResponse(t *testing.T) {
repo := newRemoteFixture(t)
endpoint := resolveFixture(t, repo)
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := t.TempDir()
response := filepath.Join(t.TempDir(), "response")
script := "#!/bin/sh\ncase \" $* \" in *' ls-remote '* ) case \" $* \" in *' --get-url '* ) ;; *) cat \"$RESPONSE\"; exit 0;; esac;; esac\nexec \"$REAL_GIT\" \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("REAL_GIT", realGit)
t.Setenv("RESPONSE", response)
object, peeled := strings.Repeat("a", 40), strings.Repeat("b", 40)
exact := object + "\trefs/tags/v1.2.3\n"
peel := peeled + "\trefs/tags/v1.2.3^{}\n"
for _, tc := range []struct {
name, output string
want RemoteRef
}{
{"empty", "", RemoteRef{State: RefAbsent}},
{"exact", exact, RemoteRef{State: RefPresent, Object: object}},
{"exact then peeled", exact + peel, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}},
{"peeled then exact", peel + exact, RemoteRef{State: RefPresent, Object: object, Peeled: peeled}},
{"peeled only", peel, RemoteRef{State: RefConflict, Peeled: peeled}},
{"duplicate", exact + exact, RemoteRef{State: RefConflict, Object: object}},
} {
t.Run(tc.name, func(t *testing.T) {
if err := os.WriteFile(response, []byte(tc.output), 0o600); err != nil {
t.Fatal(err)
}
snapshot, err := endpoint.Query(context.Background(), repo.work, "v1.2.3")
if err != nil || snapshot.Tag != tc.want {
t.Fatalf("Query tag = %#v, error = %v, want %#v", snapshot.Tag, err, tc.want)
}
got, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3")
if err != nil || got != tc.want {
t.Fatalf("QueryTag = %#v, error = %v, want %#v", got, err, tc.want)
}
})
}
}
type remoteFixture struct { type remoteFixture struct {
work string work string
bare string bare string

View File

@@ -3,14 +3,17 @@ package git
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os"
"os/exec"
"path" "path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "go.maximumdirect.net/releasekit/internal/process"
) )
// TreeEntry records one committed path and its Git object metadata. // TreeEntry records one committed path and its Git object metadata.
@@ -41,6 +44,10 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("resolve Git repository root: %w", err) return "", fmt.Errorf("resolve Git repository root: %w", err)
} }
return normalizeRepositoryRoot(root)
}
func normalizeRepositoryRoot(root string) (string, error) {
root = strings.TrimSuffix(root, "\n") root = strings.TrimSuffix(root, "\n")
resolved, err := filepath.EvalSymlinks(root) resolved, err := filepath.EvalSymlinks(root)
if err != nil { if err != nil {
@@ -49,6 +56,174 @@ func RepositoryRoot(ctx context.Context, directory string) (string, error) {
return resolved, nil return resolved, nil
} }
// MetadataDirectories returns the checkout's private and shared Git directories.
func MetadataDirectories(ctx context.Context, directory string) ([]string, error) {
var directories []string
for _, flag := range []string{"--absolute-git-dir", "--git-common-dir"} {
output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", flag)
if err != nil {
return nil, fmt.Errorf("resolve Git metadata directory: %w", err)
}
resolved, err := normalizeRepositoryRoot(output)
if err != nil {
return nil, err
}
directories = append(directories, resolved)
}
return directories, nil
}
// CommonDirectory resolves the shared Git metadata directory.
func CommonDirectory(ctx context.Context, directory string) (string, error) {
output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", "--git-common-dir")
if err != nil {
return "", fmt.Errorf("resolve shared Git metadata directory: %w", err)
}
return normalizeRepositoryRoot(output)
}
// IsIgnored checks whether Git excludes the selected worktree path.
func IsIgnored(ctx context.Context, directory, path string) (bool, error) {
_, err := run(ctx, directory, nil, "check-ignore", "-q", "--", path)
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use.
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
if directory == "" {
directory = "."
}
var stdout, stderr strings.Builder
err := process.Run(ctx, process.Invocation{
Args: []string{"git", "rev-parse", "--show-toplevel"}, Dir: directory,
Env: map[string]string{"LC_ALL": "C", "GIT_NO_REPLACE_OBJECTS": "1"}, Stdout: &stdout, Stderr: &stderr,
})
if err == nil {
root, err := normalizeRepositoryRoot(stdout.String())
return root, err == nil, err
}
if errors.Is(err, exec.ErrNotFound) {
standalone, inspectErr := standaloneWithoutGit(directory)
if inspectErr != nil {
return "", false, inspectErr
}
if standalone {
return "", false, nil
}
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 128 {
if boundary, noRepository := noRepositoryBoundary(stderr.String()); noRepository {
boundaries := discoveryCeilings()
if boundary != "" {
boundaries = append(boundaries, boundary)
}
standalone, inspectErr := standaloneDirectory(directory, boundaries)
if inspectErr != nil {
return "", false, errors.Join(inspectErr, err)
}
if standalone {
return "", false, nil
}
}
}
if message := strings.TrimSpace(stderr.String()); message != "" {
return "", false, fmt.Errorf("resolve Git repository root: %s: %w", message, err)
}
return "", false, fmt.Errorf("resolve Git repository root: %w", err)
}
func noRepositoryBoundary(message string) (string, bool) {
message = strings.TrimSpace(message)
if message == "fatal: not a git repository (or any of the parent directories): .git" {
return "", true
}
const prefix = "fatal: not a git repository (or any parent up to mount point "
const suffix = ")\nStopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set)."
if strings.HasPrefix(message, prefix) && strings.HasSuffix(message, suffix) && len(message) > len(prefix)+len(suffix) {
return filepath.Clean(message[len(prefix) : len(message)-len(suffix)]), true
}
return "", false
}
func discoveryCeilings() []string {
var ceilings []string
resolveLinks := true
for _, entry := range filepath.SplitList(os.Getenv("GIT_CEILING_DIRECTORIES")) {
// Git uses an empty entry to mark subsequent paths as already physical.
if entry == "" {
resolveLinks = false
continue
}
if !filepath.IsAbs(entry) {
continue
}
if resolveLinks {
resolved, err := filepath.EvalSymlinks(entry)
if err != nil {
continue // Git ignores ceiling entries it cannot resolve.
}
entry = resolved
}
ceilings = append(ceilings, filepath.Clean(entry))
}
return ceilings
}
func standaloneWithoutGit(directory string) (bool, error) {
_, gitDirSet := os.LookupEnv("GIT_DIR")
_, workTreeSet := os.LookupEnv("GIT_WORK_TREE")
if gitDirSet || workTreeSet {
return false, nil
}
return standaloneDirectory(directory, nil)
}
// standaloneDirectory checks only locations Git could have searched. Boundaries
// are excluded parents; the supplied directory itself is always inspected.
func standaloneDirectory(directory string, boundaries []string) (bool, error) {
abs, err := filepath.Abs(directory)
if err != nil {
return false, fmt.Errorf("inspect repository directory: %w", err)
}
physical, err := filepath.EvalSymlinks(abs)
if err != nil {
return false, fmt.Errorf("inspect repository directory: %w", err)
}
info, err := os.Stat(physical)
if err != nil {
return false, fmt.Errorf("inspect repository directory: %w", err)
}
if !info.IsDir() {
return false, fmt.Errorf("inspect repository directory %q: not a directory", directory)
}
for dir := physical; ; dir = filepath.Dir(dir) {
_, err := os.Lstat(filepath.Join(dir, ".git"))
if err == nil {
return false, nil
}
if !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("inspect Git marker in %s: %w", dir, err)
}
parent := filepath.Dir(dir)
if parent == dir {
return true, nil
}
for _, boundary := range boundaries {
if parent == boundary {
return true, nil
}
}
}
}
// CaptureHead records the checkout commit before identity derived from files // CaptureHead records the checkout commit before identity derived from files
// in that commit is inspected. // in that commit is inspected.
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) { func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
@@ -63,22 +238,28 @@ func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
return head, nil return head, nil
} }
// WorktreeStatus observes repository-wide worktree and index changes.
func WorktreeStatus(ctx context.Context, repositoryDir string) (string, error) {
status, err := run(ctx, repositoryDir, nil, "status", "--porcelain=v1", "--untracked-files=all")
if err != nil {
return "", err
}
return strings.TrimSpace(status), 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, tag string) (TaggedSource, error) { func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil { if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return TaggedSource{}, err return TaggedSource{}, err
} }
var source TaggedSource var source TaggedSource
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}") head, err := CaptureHead(ctx, repositoryDir)
if err != nil { if err != nil {
return TaggedSource{}, fmt.Errorf("read checkout HEAD: %w", err) return TaggedSource{}, err
} }
source.Head = strings.TrimSpace(head) source.Head = head
if !validObjectID(source.Head) { status, err := WorktreeStatus(ctx, repositoryDir)
return TaggedSource{}, fmt.Errorf("Git returned an invalid checkout commit %q", source.Head)
}
status, err := run(ctx, repositoryDir, nil, "status", "--porcelain", "--untracked-files=all")
if err != nil { if err != nil {
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err) return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
} }
@@ -275,7 +456,7 @@ func readLocalObjects(ctx context.Context, repositoryDir string, input io.Reader
Args: append([]string{"git"}, args...), Args: append([]string{"git"}, args...),
Dir: repositoryDir, Dir: repositoryDir,
// Disable transports as well for Git versions predating lazy-fetch control. // Disable transports as well for Git versions predating lazy-fetch control.
Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": ""}, Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": "", "GIT_NO_REPLACE_OBJECTS": "1"},
Stdin: input, Stdin: input,
Stdout: &stdout, Stdout: &stdout,
Stderr: &stderr, Stderr: &stderr,

View File

@@ -35,16 +35,13 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) { func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
fixture := newRemoteFixture(t) fixture := newRemoteFixture(t)
tag := "integrations/check/v2.3.4" tag := "integrations/check/v2.3.4"
if err := checkTagRef(context.Background(), fixture.work, tag); err != nil { if err := CheckTagRef(context.Background(), fixture.work, tag); err != nil {
t.Fatalf("checkTagRef(%q): %v", tag, err) t.Fatalf("CheckTagRef(%q): %v", tag, err)
} }
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n")) object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
if err != nil { if err != nil {
t.Fatal(err) 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) source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -54,11 +51,36 @@ func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
} }
} }
func TestCreateAnnotatedTagReportsTemporaryCleanupFailure(t *testing.T) {
fixture := newRemoteFixture(t)
cleanupErr := errors.New("cleanup failed")
var removed string
remove := func(path string) error {
removed = path
if err := os.Remove(path); err != nil {
return err
}
return cleanupErr
}
object, err := createAnnotatedTag(t.Context(), fixture.work, "v1.2.3", fixture.commit, []byte("note\n"), remove)
if !errors.Is(err, cleanupErr) || object == "" || removed == "" || !strings.Contains(err.Error(), removed) {
t.Fatalf("tag object = %q, cleanup path = %q, error = %v", object, removed, err)
}
if got := gitText(t, fixture.work, "rev-parse", "refs/tags/v1.2.3"); got != object {
t.Fatalf("local tag = %q, want %q", got, object)
}
_, err = createAnnotatedTag(t.Context(), fixture.work, "v1.2.3", fixture.commit, []byte("note\n"), remove)
var exit *exec.ExitError
if !errors.As(err, &exit) || !errors.Is(err, cleanupErr) {
t.Fatalf("tag creation and cleanup error = %v", err)
}
}
func TestTagReferenceValidationUsesGitRules(t *testing.T) { func TestTagReferenceValidationUsesGitRules(t *testing.T) {
fixture := newRemoteFixture(t) fixture := newRemoteFixture(t)
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} { 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 { if err := CheckTagRef(context.Background(), fixture.work, tag); err == nil {
t.Errorf("checkTagRef(%q) succeeded", tag) t.Errorf("CheckTagRef(%q) succeeded", tag)
} }
} }
} }
@@ -142,3 +164,52 @@ func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T)
t.Fatalf("dirty status = %q", source.Status) t.Fatalf("dirty status = %q", source.Status)
} }
} }
func TestCommittedReadsIgnoreReplacementObjects(t *testing.T) {
for _, kind := range []string{"commit", "tree", "blob"} {
t.Run(kind, func(t *testing.T) {
fixture := newRemoteFixture(t)
original := map[string]string{
"commit": fixture.commit,
"tree": gitText(t, fixture.work, "rev-parse", "HEAD^{tree}"),
"blob": gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"),
}
writeRepoFile(t, fixture.work, "source.txt", "replacement")
gitRun(t, fixture.work, "add", "source.txt")
gitRun(t, fixture.work, "commit", "-m", "Replacement")
replacement := map[string]string{
"commit": gitText(t, fixture.work, "rev-parse", "HEAD"),
"tree": gitText(t, fixture.work, "rev-parse", "HEAD^{tree}"),
"blob": gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"),
}
gitRun(t, fixture.work, "reset", "--hard", fixture.commit)
gitRun(t, fixture.work, "replace", original[kind], replacement[kind])
content, err := ReadCommitFile(t.Context(), fixture.work, fixture.commit, "source.txt")
if err != nil || string(content) != "candidate" {
t.Fatalf("committed content = %q, error = %v", content, err)
}
entries, err := ReadTree(t.Context(), fixture.work, fixture.commit)
if err != nil {
t.Fatal(err)
}
var object string
for _, entry := range entries {
if entry.Path == "source.txt" {
object = entry.ObjectID
}
}
if object != original["blob"] {
t.Fatalf("tree blob = %q, want %q", object, original["blob"])
}
reader := NewBlobReader(t.Context(), fixture.work)
content, err = reader.Read(t.Context(), object)
if err != nil || string(content) != "candidate" {
t.Fatalf("batch content = %q, error = %v", content, err)
}
if err := reader.Close(); err != nil {
t.Fatal(err)
}
})
}
}

View File

@@ -3,15 +3,21 @@ package git
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
) )
// 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. Once its type and ID are established, the ID is
// returned even if a later verification fails.
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) { func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil { return createAnnotatedTag(ctx, repositoryDir, tag, commit, note, os.Remove)
}
func createAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte, remove func(string) error) (objectID string, resultErr error) {
if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return "", err return "", err
} }
if !validObjectID(commit) { if !validObjectID(commit) {
@@ -22,7 +28,11 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
return "", fmt.Errorf("create temporary release note: %w", err) return "", fmt.Errorf("create temporary release note: %w", err)
} }
messagePath := message.Name() messagePath := message.Name()
defer os.Remove(messagePath) defer func() {
if err := remove(messagePath); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary release note %q: %w", messagePath, err))
}
}()
if _, err := message.Write(note); err != nil { if _, err := message.Write(note); err != nil {
_ = message.Close() _ = message.Close()
return "", fmt.Errorf("write temporary release note: %w", err) return "", fmt.Errorf("write temporary release note: %w", err)
@@ -35,71 +45,52 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string,
} }
ref := "refs/tags/" + tag ref := "refs/tags/" + tag
object, err := inspectCreatedTag(ctx, repositoryDir, ref, commit, note)
if err != nil {
return object, fmt.Errorf("verify created tag %s; local ref may remain: %w", ref, err)
}
return object, nil
}
func inspectCreatedTag(ctx context.Context, repositoryDir, ref, commit string, note []byte) (string, error) {
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", tag, err) return "", fmt.Errorf("inspect created tag %s: %w", ref, err)
} }
if strings.TrimSpace(tagType) != "tag" { if strings.TrimSpace(tagType) != "tag" {
return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, strings.TrimSpace(tagType)) return "", fmt.Errorf("created tag %s has object type %q, want tag", ref, 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", tag, err) return "", fmt.Errorf("read created tag object %s: %w", ref, 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", tag, tagObject) return "", fmt.Errorf("created tag %s has invalid object ID %q", ref, 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", tag, err) return tagObject, fmt.Errorf("read peeled commit for tag %s: %w", ref, err)
} }
if strings.TrimSpace(peeled) != commit { if strings.TrimSpace(peeled) != commit {
return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, strings.TrimSpace(peeled), commit) return tagObject, fmt.Errorf("created tag %s peels to %s, want candidate %s", ref, 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", tag, err) return tagObject, fmt.Errorf("read annotated tag %s: %w", ref, 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", tag) return tagObject, fmt.Errorf("annotated tag %s message does not match the captured release note", ref)
} }
return tagObject, nil return tagObject, nil
} }
// InspectLocalTag confirms the selected tag still points to the exact
// annotated tag object captured before publication.
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return err
}
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)
if err != nil {
return fmt.Errorf("inspect local tag %s: %w", tag, err)
}
if strings.TrimSpace(tagType) != "tag" {
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)
if err != nil {
return fmt.Errorf("read local tag %s: %w", tag, err)
}
if strings.TrimSpace(object) != expectedObject {
return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject)
}
return nil
}
// 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, tag string) (RemoteRef, error) { func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string) (RemoteRef, error) {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil { if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return RemoteRef{}, err return RemoteRef{}, err
} }
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil { if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
@@ -109,13 +100,13 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
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, tag, endpoint.PushURL, err) return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, DisplayURL(endpoint.PushURL), err)
} }
remoteTag := RemoteRef{State: RefAbsent} remoteTag := RemoteRef{State: RefAbsent}
for _, line := range outputLines(output) { for _, line := range outputLines(output) {
object, name, ok := strings.Cut(line, "\t") object, name, ok := strings.Cut(line, "\t")
if !ok || !validObjectID(object) { if !ok || !validObjectID(object) {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line) return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, displayDiagnostic(line))
} }
switch name { switch name {
case ref: case ref:
@@ -124,20 +115,20 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string
remoteTag = mergeRef(remoteTag, object, true) remoteTag = mergeRef(remoteTag, object, true)
} }
} }
return remoteTag, nil return finalizeRef(remoteTag), nil
} }
// 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, tag, tagObject string) error { func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil { if err := CheckTagRef(ctx, repositoryDir, tag); err != nil {
return err return err
} }
if !validObjectID(tagObject) { if !validObjectID(tagObject) {
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag) return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
} }
refspec := tagObject + ":refs/tags/" + 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", "--recurse-submodules=no", "--porcelain", endpoint.ScopedName, refspec); err != nil {
return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err) return fmt.Errorf("push annotated tag %s to %s: %w", tag, DisplayURL(endpoint.PushURL), err)
} }
return nil return nil
} }

View File

@@ -2,43 +2,14 @@ package gitea
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path"
"sort"
"strconv" "strconv"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
) )
const ManifestFilename = artifact.ManifestFilename
type AssetState string
const (
AssetMatching AssetState = "matching"
AssetMissing AssetState = "missing"
AssetConflicting AssetState = "conflicting"
AssetUnverifiable AssetState = "unverifiable"
)
// ExpectedAsset describes one declared file. Content is populated for the
// generated checksum manifest; Path identifies local binary content for later
// upload operations.
type ExpectedAsset struct {
Filename string
SHA256 string
Size int64
Path string
Content []byte
}
type RemoteAsset struct { type RemoteAsset struct {
ID int64 ID int64
Filename string Filename string
@@ -46,18 +17,6 @@ type RemoteAsset struct {
DownloadURL string DownloadURL string
} }
type AssetInspection struct {
Expected ExpectedAsset
State AssetState
Existing []RemoteAsset
Err error
}
type Inspection struct {
Assets []AssetInspection
Unrelated []RemoteAsset
}
type assetResponse struct { type assetResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -65,118 +24,48 @@ type assetResponse struct {
BrowserDownloadURL string `json:"browser_download_url"` BrowserDownloadURL string `json:"browser_download_url"`
} }
// BuildManifest creates the deterministic checksum file for the declared func (c Client) listAssets(ctx context.Context, client *http.Client, token string, releaseID int64, apiRoot string) ([]RemoteAsset, error) {
// binaries. An empty binary set produces no manifest.
func BuildManifest(binaries []ExpectedAsset) (ExpectedAsset, error) {
if len(binaries) == 0 {
return ExpectedAsset{}, nil
}
ordered := append([]ExpectedAsset(nil), binaries...)
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
var body strings.Builder
seen := make(map[string]struct{}, len(ordered))
for _, binary := range ordered {
if !validAssetName(binary.Filename) || binary.Filename == ManifestFilename || !validSHA256(binary.SHA256) {
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("invalid binary identity for %q", binary.Filename))
}
if _, exists := seen[binary.Filename]; exists {
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("duplicate binary filename %q", binary.Filename))
}
seen[binary.Filename] = struct{}{}
fmt.Fprintf(&body, "%s %s\n", binary.SHA256, binary.Filename)
}
content := []byte(body.String())
return ExpectedAsset{Filename: ManifestFilename, SHA256: digest(content), Size: int64(len(content)), Content: content}, nil
}
// InspectAssets lists the release's attachments and compares every declared
// asset by streamed SHA-256 content. It does not create or modify remote data.
func (c Client) InspectAssets(ctx context.Context, destination Destination, releaseID int64, expected []ExpectedAsset) (Inspection, error) {
if releaseID <= 0 { if releaseID <= 0 {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("release ID must be positive")) return nil, failure(FailureInvalid, "list release assets", 0, errors.New("release ID must be positive"))
} }
if strings.TrimSpace(destination.Token) == "" {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea token is required"))
}
base, err := parseBaseURL(destination.BaseURL)
if err != nil {
return Inspection{}, failure(FailureInvalid, "validate destination", 0, err)
}
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea owner and repository must be path segments"))
}
if err := validateExpectedAssets(expected); err != nil {
return Inspection{}, err
}
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets" assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
var listed []assetResponse var listed []assetResponse
if err := c.getJSON(ctx, c.httpClient(), destination.Token, "list release assets", assetsURL, &listed); err != nil { if err := c.getJSON(ctx, client, token, "list release assets", assetsURL, &listed); err != nil {
return Inspection{}, err return nil, err
} }
if listed == nil { if listed == nil {
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array")) return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
} }
assets := make([]RemoteAsset, 0, len(listed))
remoteByName := make(map[string][]RemoteAsset, len(listed))
for _, item := range listed { for _, item := range listed {
if item.ID <= 0 || item.Name == "" || item.Size < 0 { if item.ID <= 0 || item.Name == "" || item.Size < 0 {
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size")) return nil, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
} }
remote := RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL} assets = append(assets, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
remoteByName[item.Name] = append(remoteByName[item.Name], remote)
} }
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))} return assets, nil
declared := make(map[string]struct{}, len(expected))
for _, item := range expected {
declared[item.Filename] = struct{}{}
matches := remoteByName[item.Filename]
observation := AssetInspection{Expected: item, Existing: matches}
switch len(matches) {
case 0:
observation.State = AssetMissing
case 1:
remoteHash, _, err := c.downloadHash(ctx, c.httpClient(), base, destination.Token, matches[0].DownloadURL)
if err != nil {
observation.State = AssetUnverifiable
observation.Err = err
} else if remoteHash == item.SHA256 {
observation.State = AssetMatching
} else {
observation.State = AssetConflicting
}
default:
observation.State = AssetConflicting
}
inspection.Assets = append(inspection.Assets, observation)
}
for _, item := range listed {
if _, expected := declared[item.Name]; !expected {
inspection.Unrelated = append(inspection.Unrelated, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
}
}
return inspection, nil
} }
func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) { func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (io.ReadCloser, error) {
if strings.TrimSpace(rawURL) == "" { if strings.TrimSpace(rawURL) == "" {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL")) return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
} }
assetURL, err := base.Parse(rawURL) assetURL, err := base.Parse(rawURL)
if err != nil || assetURL.User != nil { if err != nil || assetURL.User != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL")) return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL"))
} }
assetURL.Fragment = "" assetURL.Fragment = ""
visited := make(map[string]struct{}) visited := make(map[string]struct{})
followed := 0
for { for {
key := assetURL.String() key := assetURL.String()
if _, exists := visited[key]; exists { if _, exists := visited[key]; exists {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop")) return nil, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
} }
visited[key] = struct{}{} visited[key] = struct{}{}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil) request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
if err != nil { if err != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err) return nil, failure(FailureUnverifiable, "download release asset", 0, err)
} }
if sameOrigin(base, assetURL) { if sameOrigin(base, assetURL) {
request.Header.Set("Authorization", "token "+token) request.Header.Set("Authorization", "token "+token)
@@ -184,65 +73,31 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
request.Header.Set("Accept", "application/octet-stream") request.Header.Set("Accept", "application/octet-stream")
response, err := client.Do(request) response, err := client.Do(request)
if err != nil { if err != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err) return nil, failure(FailureUnverifiable, "download release asset", 0, err)
} }
if isRedirect(response.StatusCode) { if isRedirect(response.StatusCode) {
if followed == 10 {
_ = response.Body.Close()
return nil, failure(FailureUnverifiable, "redirect release asset: redirect limit exceeded", response.StatusCode, errors.New("at most 10 redirects are allowed"))
}
location, locationErr := response.Location() location, locationErr := response.Location()
_ = response.Body.Close() _ = response.Body.Close()
if locationErr != nil || location.User != nil { if locationErr != nil || location.User != nil {
return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect")) return nil, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
} }
location.Fragment = "" location.Fragment = ""
assetURL = location assetURL = location
followed++
continue continue
} }
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 { if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", 0, responseFailure("download release asset", response.StatusCode) _ = response.Body.Close()
return nil, responseFailure("download release asset", response.StatusCode)
} }
hasher := sha256.New() return response.Body, nil
size, err := io.Copy(hasher, response.Body)
if err != nil {
return "", size, failure(FailureUnverifiable, "read release asset", response.StatusCode, err)
}
return hex.EncodeToString(hasher.Sum(nil)), size, nil
} }
} }
func validateExpectedAssets(expected []ExpectedAsset) error {
seen := make(map[string]struct{}, len(expected))
for _, item := range expected {
if !validAssetName(item.Filename) || !validSHA256(item.SHA256) || item.Size < 0 {
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("invalid expected asset %q", item.Filename))
}
if _, duplicate := seen[item.Filename]; duplicate {
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("duplicate expected asset %q", item.Filename))
}
seen[item.Filename] = struct{}{}
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("expected content does not match identity for %q", item.Filename))
}
}
return nil
}
func validAssetName(name string) bool {
return name != "" && name != "." && name != ".." && path.Base(name) == name && !strings.ContainsAny(name, "\\\r\n\x00")
}
func validSHA256(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil && strings.ToLower(value) == value
}
func digest(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}
func sameOrigin(base, target *url.URL) bool { func sameOrigin(base, target *url.URL) bool {
if target == nil || target.User != nil || target.Host == "" { if target == nil || target.User != nil || target.Host == "" {
return false return false

View File

@@ -2,327 +2,204 @@ package gitea
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"go.maximumdirect.net/releasekit/internal/forge"
) )
const assetListPath = "/api/v1/repos/owner/project/releases/10/assets" func TestBackendOpenAssetBoundsRedirects(t *testing.T) {
for _, hops := range []int{0, 10, 11} {
func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) { t.Run(strconv.Itoa(hops), func(t *testing.T) {
manifest, err := BuildManifest([]ExpectedAsset{ var requests atomic.Int32
assetFor("zeta.bin", []byte("zeta")), server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assetFor("alpha.bin", []byte("alpha")), requests.Add(1)
}) if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
if err != nil { t.Errorf("authorization = %q", got)
t.Fatal(err)
}
want := fmt.Sprintf("%s alpha.bin\n%s zeta.bin\n", digest([]byte("alpha")), digest([]byte("zeta")))
if string(manifest.Content) != want || manifest.Filename != ManifestFilename || manifest.SHA256 != digest([]byte(want)) || manifest.Size != int64(len(want)) {
t.Fatalf("manifest = %#v, content %q", manifest, manifest.Content)
}
empty, err := BuildManifest(nil)
if err != nil || empty.Filename != "" || len(empty.Content) != 0 {
t.Fatalf("empty manifest = %#v, error = %v", empty, err)
}
}
func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) {
content := []byte("selected binary")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
switch r.URL.Path {
case assetListPath:
writeAssetList(t, w, []assetResponse{
{ID: 4, Name: "app-v1.2.3-linux-amd64", Size: int64(len(content)), BrowserDownloadURL: "/files/app"},
{ID: 8, Name: "notes.txt", Size: 9, BrowserDownloadURL: "/files/notes"},
})
case "/files/app":
_, _ = w.Write(content)
case "/files/notes":
_, _ = w.Write([]byte("unrelated"))
default:
t.Errorf("unexpected path %s", r.URL.Path)
return
}
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app-v1.2.3-linux-amd64", content)})
if err != nil {
t.Fatal(err)
}
if len(inspection.Assets) != 1 || inspection.Assets[0].State != AssetMatching {
t.Fatalf("inspection = %#v", inspection)
}
if len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "notes.txt" {
t.Fatalf("unrelated = %#v", inspection.Unrelated)
}
}
func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) {
expected := []ExpectedAsset{assetFor("app.bin", []byte("alpha")), assetFor("other.bin", []byte("beta"))}
var appDownloads, otherDownloads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{
{ID: 2, Name: expected[0].Filename, Size: 5, BrowserDownloadURL: "/files/app"},
{ID: 3, Name: expected[1].Filename, Size: 4, BrowserDownloadURL: "/files/other"},
})
return
}
if r.URL.Path == "/files/app" {
appDownloads.Add(1)
_, _ = w.Write([]byte("bravo"))
return
}
otherDownloads.Add(1)
_, _ = w.Write([]byte("beta"))
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, expected)
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetConflicting || inspection.Assets[1].State != AssetMatching || appDownloads.Load() != 1 || otherDownloads.Load() != 1 {
t.Fatalf("inspection = %#v, downloads = %d and %d", inspection, appDownloads.Load(), otherDownloads.Load())
}
}
func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
manifest, err := BuildManifest(binaries)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{{ID: 3, Name: manifest.Filename, Size: manifest.Size, BrowserDownloadURL: "/files/manifest"}})
return
}
if r.URL.Path != "/files/manifest" {
t.Errorf("unexpected path %s", r.URL.Path)
return
}
_, _ = w.Write(manifest.Content)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, append(binaries, manifest))
if err != nil {
t.Fatal(err)
}
if len(inspection.Assets) != 3 || inspection.Assets[0].State != AssetMissing || inspection.Assets[1].State != AssetMissing || inspection.Assets[2].State != AssetMatching {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsRejectsSubsetManifest(t *testing.T) {
binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))}
full, err := BuildManifest(binaries)
if err != nil {
t.Fatal(err)
}
subset, err := BuildManifest(binaries[:1])
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{{ID: 3, Name: ManifestFilename, Size: subset.Size, BrowserDownloadURL: "/files/manifest"}})
return
}
_, _ = w.Write(subset.Content)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{full})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetConflicting {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsRejectsDifferentManifest(t *testing.T) {
expected, err := BuildManifest([]ExpectedAsset{assetFor("alpha.bin", []byte("alpha"))})
if err != nil {
t.Fatal(err)
}
different := []byte("not the declared checksum list\n")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{{ID: 3, Name: ManifestFilename, Size: int64(len(different)), BrowserDownloadURL: "/files/manifest"}})
return
}
_, _ = w.Write(different)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetConflicting {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsMarksUnreadableContentUnverifiable(t *testing.T) {
expected := assetFor("app.bin", []byte("alpha"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{{ID: 2, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/app"}})
return
}
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetUnverifiable || inspection.Assets[0].Err == nil {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsRejectsMalformedAttachmentList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, assetListPath)
_, _ = w.Write([]byte("null"))
}))
defer server.Close()
_, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app.bin", []byte("alpha"))})
if failureKind(t, err) != FailureMalformed {
t.Fatalf("error = %v", err)
}
}
func TestInspectAssetsSourceOnlyDoesNotDeclareManifest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, assetListPath)
writeAssetList(t, w, []assetResponse{{ID: 7, Name: "historical.txt", Size: 3}})
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, nil)
if err != nil {
t.Fatal(err)
}
if len(inspection.Assets) != 0 || len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "historical.txt" {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
content := []byte("alpha")
expected := assetFor("app.bin", content)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/redirect"}})
return
}
if r.URL.Path == "/files/redirect" {
http.Redirect(w, r, "/files/content", http.StatusFound)
return
}
if r.URL.Path == "/files/content" {
_, _ = w.Write(content)
return
}
t.Errorf("unexpected path %s", r.URL.Path)
return
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetMatching {
t.Fatalf("inspection = %#v", inspection)
}
}
func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
for _, mode := range []string{"direct", "redirect", "conflicting"} {
t.Run(mode, func(t *testing.T) {
expected := assetFor("app.bin", []byte("matching bytes"))
var externalGets atomic.Int64
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
externalGets.Add(1)
if r.Header.Get("Authorization") != "" {
t.Error("Gitea token reached external host")
} }
content := []byte("matching bytes") step, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/"))
if mode == "conflicting" { if err != nil {
content = []byte("different bytes") t.Errorf("invalid path %q", r.URL.Path)
}
w.Write(content)
}))
defer external.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
download := external.URL + "/content"
if mode == "redirect" {
download = "/redirect"
}
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}})
return return
} }
http.Redirect(w, r, external.URL+"/content", http.StatusFound) if step < hops {
http.Redirect(w, r, fmt.Sprintf("/%d", step+1), http.StatusFound)
return
}
_, _ = io.WriteString(w, "asset")
})) }))
defer origin.Close() defer server.Close()
inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []ExpectedAsset{expected}) backend := NewBackend(testDestination(server.URL), server.Client())
if err != nil { stream, err := backend.OpenAsset(t.Context(), assetHandle{downloadURL: "/0"})
t.Fatal(err) if hops <= 10 {
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(stream)
closeErr := stream.Close()
if readErr != nil || closeErr != nil || string(body) != "asset" || requests.Load() != int32(hops+1) {
t.Fatalf("body = %q, read = %v, close = %v, requests = %d", body, readErr, closeErr, requests.Load())
}
return
} }
want := AssetMatching var failure *forge.Failure
if mode == "conflicting" { if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "redirect limit") || requests.Load() != 11 || stream != nil {
want = AssetConflicting t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
}
if inspection.Assets[0].State != want || externalGets.Load() != 1 {
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load())
} }
}) })
} }
} }
func assetFor(filename string, content []byte) ExpectedAsset { func TestBackendOpenAssetRedirectReturnsToOrigin(t *testing.T) {
sum := sha256.Sum256(content) var origin *httptest.Server
return ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)} external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
} if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("cross-origin authorization = %q", got)
func writeAssetList(t *testing.T, w http.ResponseWriter, assets []assetResponse) { }
t.Helper() http.Redirect(w, r, origin.URL+"/final", http.StatusFound)
w.Header().Set("Content-Type", "application/json") }))
if err := json.NewEncoder(w).Encode(assets); err != nil { defer external.Close()
t.Error(err) origin = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
t.Errorf("origin authorization = %q", got)
}
if r.URL.Path == "/start" {
http.Redirect(w, r, external.URL+"/away", http.StatusFound)
return
}
_, _ = io.WriteString(w, "asset")
}))
defer origin.Close()
stream, err := NewBackend(testDestination(origin.URL), origin.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/start"})
if err != nil {
t.Fatal(err)
}
defer stream.Close()
if body, err := io.ReadAll(stream); err != nil || string(body) != "asset" {
t.Fatalf("body = %q, error = %v", body, err)
} }
} }
func assertAssetGET(t *testing.T, r *http.Request, expectedPath string) { func TestBackendOpenAssetRejectsRedirectCycle(t *testing.T) {
t.Helper() var requests atomic.Int32
if r.Method != http.MethodGet { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("method = %q, want GET", r.Method) requests.Add(1)
} http.Redirect(w, r, "/loop#different-fragment", http.StatusFound)
if expectedPath != "" && r.URL.Path != expectedPath { }))
t.Errorf("path = %q, want %q", r.URL.Path, expectedPath) defer server.Close()
} stream, err := NewBackend(testDestination(server.URL), server.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/loop"})
if got := r.Header.Get("Authorization"); got != "token "+testToken { var failure *forge.Failure
t.Errorf("Authorization = %q", got) if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "loop") || requests.Load() != 1 || stream != nil {
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
} }
} }
func testDestination(base string) Destination { func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
return Destination{BaseURL: base, Owner: "owner", Repository: "project", Token: testToken} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
t.Errorf("authorization = %q, want %q", got, want)
}
_, _ = io.WriteString(w, "null")
}))
defer server.Close()
_, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
var failure *forge.Failure
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed {
t.Fatalf("ListAssets() error = %v, want malformed attachment response", err)
}
}
func TestBackendOpenAssetFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
t.Errorf("authorization at %s = %q, want %q", r.URL.Path, got, want)
}
switch r.URL.Path {
case "/files/redirect":
http.Redirect(w, r, "/files/content", http.StatusFound)
case "/files/content":
_, _ = io.WriteString(w, "streamed asset")
default:
t.Errorf("unexpected path %q", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
backend := NewBackend(testDestination(server.URL), server.Client())
stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: "/files/redirect"})
if err != nil {
t.Fatal(err)
}
content, readErr := io.ReadAll(stream)
closeErr := stream.Close()
if readErr != nil || closeErr != nil || string(content) != "streamed asset" {
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
}
}
func TestBackendOpenAssetDoesNotForwardAuthorizationAcrossOrigin(t *testing.T) {
for _, mode := range []string{"direct", "redirect"} {
t.Run(mode, func(t *testing.T) {
var externalRequests atomic.Int32
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
externalRequests.Add(1)
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("external authorization = %q, want empty", got)
}
_, _ = io.WriteString(w, "external asset")
}))
defer external.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
t.Errorf("origin authorization = %q, want %q", got, want)
}
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
}))
defer origin.Close()
downloadURL := external.URL + "/content"
if mode == "redirect" {
downloadURL = "/redirect"
}
backend := NewBackend(testDestination(origin.URL), origin.Client())
stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: downloadURL})
if err != nil {
t.Fatal(err)
}
content, readErr := io.ReadAll(stream)
closeErr := stream.Close()
if readErr != nil || closeErr != nil || string(content) != "external asset" || externalRequests.Load() != 1 {
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v, external requests = %d", content, readErr, closeErr, externalRequests.Load())
}
})
}
}
func TestBackendListAssetsDecodesAttachmentMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
}))
defer server.Close()
assets, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10})
if err != nil {
t.Fatal(err)
}
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
t.Fatalf("ListAssets() = %#v", assets)
}
} }

Some files were not shown because too many files have changed in this diff Show More