diff --git a/cmd/releasekit/assets_test.go b/cmd/releasekit/assets_test.go new file mode 100644 index 0000000..d7d42a4 --- /dev/null +++ b/cmd/releasekit/assets_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" +) + +func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) { + for _, tt := range []struct { + name, module string + absolute bool + }{ + {name: "root relative"}, + {name: "nested relative", module: "apps/widget"}, + {name: "nested absolute", module: "apps/widget", absolute: true}, + } { + t.Run(tt.name, func(t *testing.T) { + repository := newDeliveryBuildRepository(t) + moduleDirectory := repository + tag := "v1.2.3" + if tt.module != "" { + moduleDirectory = filepath.Join(repository, filepath.FromSlash(tt.module)) + tag = tt.module + "/v1.2.3" + for _, name := range []string{"go.mod", "main.go", "docs/releases/v1.2.3.md"} { + contents, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(name))) + if err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repository, tt.module+"/"+name, string(contents)) + } + } + writeRepositoryFile(t, repository, ".gitignore", "dist/\n") + state := &deliveryServerState{tag: tag} + server := newDeliveryServer(t, state) + defer server.Close() + settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module, + Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, + Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + } + writeDeliveryProfile(t, repository, settings) + cliGitRun(t, repository, "add", ".") + cliGitRun(t, repository, "commit", "-m", "Prepare module source and output exclusion") + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", tag) + output, resolved := "dist", filepath.Join(moduleDirectory, "dist") + if tt.absolute { + output = filepath.Join(t.TempDir(), "assets") + resolved = output + } + var stdout, stderr bytes.Buffer + if status := run(context.Background(), []string{"build", "--repo", repository, "--output", output, "v1.2.3"}, &stdout, &stderr); status != 0 { + t.Fatalf("build status=%d, stderr=%s", status, stderr.String()) + } + receipt, err := cli.LoadBuildReceipt(filepath.Join(resolved, cli.BuildReceiptName)) + if err != nil || receipt.ModuleDir != moduleDirectory || receipt.Tag != tag { + t.Fatalf("build receipt=%#v, error=%v", receipt, err) + } + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + args := []string{"deliver", "--repo", repository, "--assets", output, "v1.2.3"} + stdout.Reset() + stderr.Reset() + if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.releaseCreates != 1 || state.assetUploads != 2 { + t.Fatalf("delivery status=%d, creates=%d, uploads=%d, stderr=%s", status, state.releaseCreates, state.assetUploads, stderr.String()) + } + if tt.module != "" { + cliGitRun(t, repository, "tag", "v1.2.3") + wrongModule := settings + wrongModule.ModuleDir = "" + invocation := cli.Invocation{Repository: repository, AssetsDir: resolved, Version: "v1.2.3"} + requestsBefore := state.apiRequests + if status := deliverWithSettings(context.Background(), invocation, wrongModule, &stdout, &stderr); status != 1 || state.apiRequests != requestsBefore { + t.Fatalf("wrong module status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.apiRequests, stderr.String()) + } + } + if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + requestsBefore := state.apiRequests + if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.apiRequests != requestsBefore { + t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.apiRequests, stderr.String()) + } + }) + } +} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index ecbcc1a..62db70c 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -14,6 +14,7 @@ import ( "gitea.maximumdirect.net/eric/ReleaseKit" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" ) @@ -342,10 +343,12 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett } if hasTargets { if invocation.AssetsDir != "" { - assetsDir := invocation.AssetsDir - if !filepath.IsAbs(assetsDir) { - assetsDir = filepath.Join(invocation.Repository, assetsDir) + selection, err := releaseidentity.ResolveSelection(invocation.Repository, settings.ModuleDir) + if err != nil { + fmt.Fprintf(stderr, "resolve asset module directory: %v\n", err) + return 1 } + assetsDir := artifact.OutputDirectory(selection.ModuleDir, invocation.AssetsDir) receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName)) if err != nil { fmt.Fprintf(stderr, "load verified build receipt: %v\n", err) diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 4b7aeac..ee243ef 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -1136,6 +1136,7 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet } type deliveryServerState struct { + tag string malformedCreate bool failAssetListing bool failAssetDownloads bool @@ -1155,6 +1156,9 @@ type deliveryServerState struct { func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server { t.Helper() + if state.tag == "" { + state.tag = "v1.2.3" + } if state.assets == nil { state.assets = make(map[string][]byte) } @@ -1167,7 +1171,7 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve } } switch r.URL.Path { - case "/api/v1/repos/owner/example/tags/v1.2.3": + case "/api/v1/repos/owner/example/tags/" + state.tag: if state.commit == "" { t.Error("tag source commit was not initialized") } @@ -1175,8 +1179,8 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve if commit == "" { commit = state.commit } - _, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit) - case "/api/v1/repos/owner/example/releases/tags/v1.2.3": + _, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, state.tag, commit) + case "/api/v1/repos/owner/example/releases/tags/" + state.tag: if !state.pageExists { http.NotFound(w, r) return @@ -1197,13 +1201,13 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("decode create payload: %v", err) } - if payload.Tag != "v1.2.3" || payload.Target != state.commit { + if payload.Tag != state.tag || payload.Target != state.commit { t.Errorf("release source payload = %#v", payload) } state.pageExists, state.releaseCreates, state.noteBody = true, state.releaseCreates+1, payload.Body if state.malformedCreate { w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "tag_name": "v1.2.3", "name": "inconsistent response", "body": payload.Body}) + _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "tag_name": state.tag, "name": "inconsistent response", "body": payload.Body}) return } writeDeliveryRelease(w, serverURL, state) @@ -1282,7 +1286,7 @@ func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *delive if product == "" { product = "Example Product" } - _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": "v1.2.3", "target_commitish": state.commit, "name": product + " v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false}) + _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": state.tag, "target_commitish": state.commit, "name": product + " v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false}) } func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) { diff --git a/docs/cli.md b/docs/cli.md index c214ad6..49c44ad 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -185,7 +185,9 @@ the tagged source into temporary storage, removes it when the operation ends, and uploads its binaries and complete `SHA256SUMS`. When `--assets DIR` is supplied, that directory must contain the private `.releasekit-build.json` receipt and matching unchanged artifacts; this keeps the caller's original -bytes available for retries. Profiles without targets use page-only delivery +bytes available for retries. Relative `--assets` paths use the selected module +directory, matching `build --output`; absolute paths retain their supplied base. +Profiles without targets use page-only delivery and need no receipt, binary build, or manifest. `deliver` never runs candidate checks or publishes a tag. diff --git a/docs/roadmap/next-release-notes.md b/docs/roadmap/next-release-notes.md index 5d5ef8a..98166a3 100644 --- a/docs/roadmap/next-release-notes.md +++ b/docs/roadmap/next-release-notes.md @@ -46,6 +46,10 @@ version, complete tag, source commit, normalized build settings, and artifact declarations. Older and unversioned receipts are rejected; rebuild with the updated `build` command before using `deliver --assets DIR`. +Relative `build --output DIR` and `deliver --assets DIR` paths both use the +selected module directory. Nested-module consumers can use the same relative +directory for building and retrying delivery; absolute directories remain usable. + The maintained [multi-module example](../../examples/multi-module/) shares a four-column `release-modules.tsv` and POSIX selector between its local launcher and mapped Woodpecker workflow. The workflow verifies and detaches the triggering diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 6d0c4d6..d018fff 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -99,7 +99,14 @@ later partial-artifact behavior intact. ## Issue 3: Use the module path base for relative prebuilt assets (P2) -Status: open. Category: incomplete module path adoption. +Status: complete. Category: incomplete module path adoption. + +**Completion:** Build output and CLI prebuilt assets share the selected module's +directory resolver. CLI and pending migration guidance describe the common base. +Local hosted regression tests verify root and nested relative build/delivery +pairs, absolute output, module binding, and rejection of changed artifact content +before hosted requests. Existing receipt/content checks and nested build cases +pass offline. **Evidence:** [CLI prebuilt delivery](../../cmd/releasekit/main.go) joins a relative `--assets` directory to `invocation.Repository`. Builds now resolve diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go index 3cc2f61..9bc8e9e 100644 --- a/internal/artifact/artifact.go +++ b/internal/artifact/artifact.go @@ -3,11 +3,24 @@ package artifact import ( "fmt" + "path/filepath" "strings" ) const ManifestFilename = "SHA256SUMS" +// OutputDirectory resolves build output and prebuilt asset directories from +// the absolute selected module directory. +func OutputDirectory(moduleDir, directory string) string { + if directory == "" { + directory = "dist" + } + if !filepath.IsAbs(directory) { + directory = filepath.Join(moduleDir, directory) + } + return filepath.Clean(directory) +} + type Target struct { OS string Arch string diff --git a/internal/build/build.go b/internal/build/build.go index 846c2b4..657e060 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -113,11 +113,11 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) { } if len(result.Targets) == 0 { - result.OutputDir = resolvedOutputDir(moduleDir, request.OutputDir) + result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir) return result, nil } - result.OutputDir = resolvedOutputDir(moduleDir, request.OutputDir) + result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir) declarations := artifact.ExpectedBuild(settings.BinaryName, identity.Version, settings.Targets) filenames := make([]string, len(declarations)) for i, declaration := range declarations { @@ -204,19 +204,6 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) { return result, nil } -func resolvedOutputDir(moduleDir, outputDir string) string { - if outputDir == "" { - outputDir = filepath.Join(moduleDir, "dist") - } else if !filepath.IsAbs(outputDir) { - outputDir = filepath.Join(moduleDir, outputDir) - } - resolved, err := filepath.Abs(outputDir) - if err != nil { - return filepath.Clean(outputDir) - } - return resolved -} - 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()