diff --git a/cmd/releasekit/assets_test.go b/cmd/releasekit/assets_test.go index d7d42a4..7a6b5dc 100644 --- a/cmd/releasekit/assets_test.go +++ b/cmd/releasekit/assets_test.go @@ -14,10 +14,12 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) { for _, tt := range []struct { name, module string absolute bool + alias bool }{ {name: "root relative"}, {name: "nested relative", module: "apps/widget"}, {name: "nested absolute", module: "apps/widget", absolute: true}, + {name: "nested checkout alias", module: "apps/widget", absolute: true, alias: true}, } { t.Run(tt.name, func(t *testing.T) { repository := newDeliveryBuildRepository(t) @@ -53,7 +55,14 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) { 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 { + invocationRepository := repository + if tt.alias { + invocationRepository = filepath.Join(t.TempDir(), "checkout-alias") + if err := os.Symlink(repository, invocationRepository); err != nil { + t.Fatal(err) + } + } + if status := run(context.Background(), []string{"build", "--repo", invocationRepository, "--output", output, "v1.2.3"}, &stdout, &stderr); status != 0 { t.Fatalf("build status=%d, stderr=%s", status, stderr.String()) } receipt, err := cli.LoadBuildReceipt(filepath.Join(resolved, cli.BuildReceiptName)) @@ -61,12 +70,17 @@ func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) { 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"} + args := []string{"deliver", "--repo", invocationRepository, "--assets", output, "v1.2.3"} stdout.Reset() stderr.Reset() if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.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.alias { + if status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", output, "v1.2.3"}, &stdout, &stderr); status != 0 || state.assetUploads != 2 { + t.Fatalf("physical-path retry status=%d, uploads=%d, stderr=%s", status, state.assetUploads, stderr.String()) + } + } if tt.module != "" { cliGitRun(t, repository, "tag", "v1.2.3") wrongModule := settings diff --git a/cmd/releasekit/internal/cli/receipt.go b/cmd/releasekit/internal/cli/receipt.go index a751a3d..e455afc 100644 --- a/cmd/releasekit/internal/cli/receipt.go +++ b/cmd/releasekit/internal/cli/receipt.go @@ -122,15 +122,14 @@ func LoadBuildReceipt(path string) (BuildReceipt, error) { } return BuildReceipt{}, err } - if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) { + if !sameDirectory(receipt.OutputDir, filepath.Dir(resolvedPath)) { return BuildReceipt{}, errors.New("build receipt output directory does not match its location") } return receipt, nil } func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool { - resolvedRepo, err := filepath.Abs(repositoryDir) - if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) { + if !sameDirectory(receipt.RepositoryDir, repositoryDir) { return false } if receipt.CandidateCommit != commit || receipt.Version != version { @@ -149,6 +148,15 @@ func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, return true } +func sameDirectory(first, second string) bool { + firstInfo, err := os.Stat(first) + if err != nil { + return false + } + secondInfo, err := os.Stat(second) + return err == nil && os.SameFile(firstInfo, secondInfo) +} + func validateBuildReceipt(receipt BuildReceipt) error { if receipt.SchemaVersion != BuildReceiptSchemaVersion { return fmt.Errorf("unsupported or missing build receipt schema version %d", receipt.SchemaVersion) diff --git a/cmd/releasekit/internal/cli/receipt_test.go b/cmd/releasekit/internal/cli/receipt_test.go index 47a07e7..2a595a8 100644 --- a/cmd/releasekit/internal/cli/receipt_test.go +++ b/cmd/releasekit/internal/cli/receipt_test.go @@ -66,6 +66,23 @@ func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) { if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { t.Fatalf("receipt does not match normalized build settings: %#v", loaded) } + aliases := t.TempDir() + repositoryAlias, outputAlias := filepath.Join(aliases, "repository"), filepath.Join(aliases, "output") + if err := os.Symlink(repositoryDir, repositoryAlias); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outputDir, outputAlias); err != nil { + t.Fatal(err) + } + if !loaded.MatchesBuild(repositoryAlias, receipt.CandidateCommit, receipt.Version, settings) { + t.Fatal("receipt rejected an alias of its source repository") + } + if _, err := LoadBuildReceipt(filepath.Join(outputAlias, BuildReceiptName)); err != nil { + t.Fatalf("load receipt through output alias: %v", err) + } + if loaded.MatchesBuild(t.TempDir(), receipt.CandidateCommit, receipt.Version, settings) { + t.Fatal("receipt matched a different repository") + } settings.BinaryName = "other" if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { t.Fatal("receipt matched changed build settings") diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index d018fff..e4ab731 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -128,7 +128,14 @@ still happen before hosted requests. ## Issue 4: Compare receipt repository identity consistently through aliases (P2) -Status: open. Category: regression from mixed path normalization. +Status: complete. Category: regression from mixed path normalization. + +**Completion:** Receipt source and location comparisons use filesystem directory +identity, accepting aliases of the same checkout or output directory. Regression +tests build and deliver through a checkout symlink, retry through its physical +path, and reject different repositories and modules. Receipt structure, exact +release binding, and delivery-owned content checks remain intact. Receipt and +assembled prebuilt-delivery cases pass offline. **Evidence:** Release identity canonicalizes repository symlinks, so generated receipts record the physical repository directory.