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"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
) )
@@ -342,10 +343,12 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
} }
if hasTargets { if hasTargets {
if invocation.AssetsDir != "" { if invocation.AssetsDir != "" {
assetsDir := invocation.AssetsDir selection, err := releaseidentity.ResolveSelection(invocation.Repository, settings.ModuleDir)
if !filepath.IsAbs(assetsDir) { if err != nil {
assetsDir = filepath.Join(invocation.Repository, assetsDir) fmt.Fprintf(stderr, "resolve asset module directory: %v\n", err)
return 1
} }
assetsDir := artifact.OutputDirectory(selection.ModuleDir, invocation.AssetsDir)
receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName)) receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "load verified build receipt: %v\n", err) fmt.Fprintf(stderr, "load verified build receipt: %v\n", err)

View File

@@ -1136,6 +1136,7 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
} }
type deliveryServerState struct { type deliveryServerState struct {
tag string
malformedCreate bool malformedCreate bool
failAssetListing bool failAssetListing bool
failAssetDownloads bool failAssetDownloads bool
@@ -1155,6 +1156,9 @@ type deliveryServerState struct {
func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server { func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server {
t.Helper() t.Helper()
if state.tag == "" {
state.tag = "v1.2.3"
}
if state.assets == nil { if state.assets == nil {
state.assets = make(map[string][]byte) state.assets = make(map[string][]byte)
} }
@@ -1167,7 +1171,7 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
} }
} }
switch r.URL.Path { 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 == "" { if state.commit == "" {
t.Error("tag source commit was not initialized") t.Error("tag source commit was not initialized")
} }
@@ -1175,8 +1179,8 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
if commit == "" { if commit == "" {
commit = state.commit commit = state.commit
} }
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit) _, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, state.tag, commit)
case "/api/v1/repos/owner/example/releases/tags/v1.2.3": case "/api/v1/repos/owner/example/releases/tags/" + state.tag:
if !state.pageExists { if !state.pageExists {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -1197,13 +1201,13 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode create payload: %v", err) 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) t.Errorf("release source payload = %#v", payload)
} }
state.pageExists, state.releaseCreates, state.noteBody = true, state.releaseCreates+1, payload.Body state.pageExists, state.releaseCreates, state.noteBody = true, state.releaseCreates+1, payload.Body
if state.malformedCreate { if state.malformedCreate {
w.WriteHeader(http.StatusCreated) 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 return
} }
writeDeliveryRelease(w, serverURL, state) writeDeliveryRelease(w, serverURL, state)
@@ -1282,7 +1286,7 @@ func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *delive
if product == "" { if product == "" {
product = "Example 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) { 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 and uploads its binaries and complete `SHA256SUMS`. When `--assets DIR` is
supplied, that directory must contain the private `.releasekit-build.json` supplied, that directory must contain the private `.releasekit-build.json`
receipt and matching unchanged artifacts; this keeps the caller's original receipt and matching unchanged artifacts; this keeps the caller's original
bytes available for retries. Profiles without targets use page-only delivery bytes available for retries. Relative `--assets` paths use the selected module
directory, matching `build --output`; absolute paths retain their supplied base.
Profiles without targets use page-only delivery
and need no receipt, binary build, or manifest. `deliver` never runs candidate and need no receipt, binary build, or manifest. `deliver` never runs candidate
checks or publishes a tag. checks or publishes a tag.

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 declarations. Older and unversioned receipts are rejected; rebuild with the
updated `build` command before using `deliver --assets DIR`. 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 The maintained [multi-module example](../../examples/multi-module/) shares a
four-column `release-modules.tsv` and POSIX selector between its local launcher four-column `release-modules.tsv` and POSIX selector between its local launcher
and mapped Woodpecker workflow. The workflow verifies and detaches the triggering 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) ## 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 **Evidence:** [CLI prebuilt delivery](../../cmd/releasekit/main.go) joins a
relative `--assets` directory to `invocation.Repository`. Builds now resolve relative `--assets` directory to `invocation.Repository`. Builds now resolve

View File

@@ -3,11 +3,24 @@ package artifact
import ( import (
"fmt" "fmt"
"path/filepath"
"strings" "strings"
) )
const ManifestFilename = "SHA256SUMS" const ManifestFilename = "SHA256SUMS"
// OutputDirectory resolves build output and prebuilt asset directories from
// the absolute selected module directory.
func OutputDirectory(moduleDir, directory string) string {
if directory == "" {
directory = "dist"
}
if !filepath.IsAbs(directory) {
directory = filepath.Join(moduleDir, directory)
}
return filepath.Clean(directory)
}
type Target struct { type Target struct {
OS string OS string
Arch string Arch string

View File

@@ -113,11 +113,11 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
} }
if len(result.Targets) == 0 { if len(result.Targets) == 0 {
result.OutputDir = resolvedOutputDir(moduleDir, request.OutputDir) result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
return result, nil 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) declarations := artifact.ExpectedBuild(settings.BinaryName, identity.Version, settings.Targets)
filenames := make([]string, len(declarations)) filenames := make([]string, len(declarations))
for i, declaration := range declarations { for i, declaration := range declarations {
@@ -204,19 +204,6 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) {
return result, nil 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) { func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}") command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
output, err := command.Output() output, err := command.Output()