Resolve prebuilt assets from the selected module

This commit is contained in:
2026-10-10 14:08:11 +00:00
parent f059fef320
commit d44ff1260d
8 changed files with 135 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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