diff --git a/cmd/releasekit/internal/cli/receipt.go b/cmd/releasekit/internal/cli/receipt.go index bb074fe..9f50b46 100644 --- a/cmd/releasekit/internal/cli/receipt.go +++ b/cmd/releasekit/internal/cli/receipt.go @@ -32,6 +32,9 @@ type ReceiptArtifact struct { // BuildReceipt is private local coordination data for generated binaries. type BuildReceipt struct { RepositoryDir string `json:"repository_dir"` + ModuleDir string `json:"module_dir"` + ModulePath string `json:"module_path"` + Tag string `json:"tag"` CandidateCommit string `json:"candidate_commit"` SourceVerified bool `json:"source_verified"` Version string `json:"version"` @@ -144,8 +147,11 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error { if !versioncontract.ValidStableVersion(receipt.Version) { return fmt.Errorf("build receipt has invalid version %q", receipt.Version) } - if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.OutputDir) { - return errors.New("build receipt repository and output directories must be absolute") + if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.ModuleDir) || !filepath.IsAbs(receipt.OutputDir) { + return errors.New("build receipt repository, module, and output directories must be absolute") + } + if strings.TrimSpace(receipt.ModulePath) == "" || strings.TrimSpace(receipt.Tag) == "" { + return errors.New("build receipt is missing module path or full tag") } if !validCommitID(receipt.CandidateCommit) { return errors.New("build receipt has invalid candidate commit") diff --git a/cmd/releasekit/internal/cli/receipt_test.go b/cmd/releasekit/internal/cli/receipt_test.go index 936f86b..e6e1c43 100644 --- a/cmd/releasekit/internal/cli/receipt_test.go +++ b/cmd/releasekit/internal/cli/receipt_test.go @@ -21,6 +21,9 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) { digest := sha256.Sum256(contents) receipt := BuildReceipt{ RepositoryDir: repositoryDir, + ModuleDir: repositoryDir, + ModulePath: "example.com/receipt", + Tag: "v1.2.3", CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: "./cmd/example", diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 76b20dd..7b31613 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -359,7 +359,8 @@ func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) { func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult { result := &releasekit.BuildResult{ - RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified, + RepositoryDir: receipt.RepositoryDir, ModuleDir: receipt.ModuleDir, ModulePath: receipt.ModulePath, Tag: receipt.Tag, + CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified, Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName, OutputDir: receipt.OutputDir, Compiler: receipt.Compiler, } @@ -394,6 +395,9 @@ func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec { func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt { receipt := cli.BuildReceipt{ RepositoryDir: result.RepositoryDir, + ModuleDir: result.ModuleDir, + ModulePath: result.ModulePath, + Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Version: result.Version, diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index a447f11..ed2539a 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -937,9 +937,10 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet digest := sha256.Sum256(content) target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"} receipt := cli.BuildReceipt{ - RepositoryDir: resolvedRepository, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), - SourceVerified: true, - Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, + RepositoryDir: resolvedRepository, ModuleDir: resolvedRepository, ModulePath: "example.com/cli-candidate", Tag: "v1.2.3", + CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), + SourceVerified: true, + Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture", Artifacts: []cli.ReceiptArtifact{{Path: artifactPath, Filename: filename, Target: target, SHA256: hex.EncodeToString(digest[:])}}, } diff --git a/delivery.go b/delivery.go index bb2472a..551a17e 100644 --- a/delivery.go +++ b/delivery.go @@ -48,9 +48,11 @@ type DeliveryRequest struct { RepositoryDir string // ModuleDir selects a repository-relative module directory; empty selects // the repository root. - ModuleDir string - Product string - Version string + ModuleDir string + Product string + Version string + // ExpectedTag optionally binds delivery to the full tag that triggered a CI event. + ExpectedTag string Destination GiteaDestination // Build selects binaries to build in temporary storage outside the checkout. Build *BuildSpec @@ -105,6 +107,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro ModuleDir: request.ModuleDir, Product: request.Product, Version: request.Version, + ExpectedTag: request.ExpectedTag, Destination: destination, Build: internalBuildSpec(request.Build), Prebuilt: prebuilt, diff --git a/delivery_test.go b/delivery_test.go index 9c11450..f684c86 100644 --- a/delivery_test.go +++ b/delivery_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "runtime" @@ -190,6 +191,14 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { repository := newTaggedDeliveryConsumer(t) + prebuilt := test.prebuilt + if prebuilt != nil { + built, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: repository, Version: "v1.2.3"}) + if err != nil { + t.Fatalf("Build() for page-only prebuilt identity = %#v, %v", built, err) + } + prebuilt = &built + } temporaryRoot := t.TempDir() t.Setenv("TMPDIR", temporaryRoot) server, remote := newFacadeDeliveryServer(t, repository) @@ -202,7 +211,7 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) { Version: "v1.2.3", Destination: facadeDeliveryDestination(server.URL), Build: test.build, - Prebuilt: test.prebuilt, + Prebuilt: prebuilt, Output: &progress, }) if err != nil || result.State != releasekit.DeliveryComplete { @@ -221,6 +230,106 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) { } } +func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) { + repository := newTwoModuleDeliveryConsumer(t) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + + wrongTag, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + ExpectedTag: "integrations/wrong/v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + }) + if err == nil || wrongTag.Tag != "v1.2.3" || remote.requests != 0 { + t.Fatalf("delivery with inconsistent expected tag = %#v, %v; hosted requests %d", wrongTag, err, remote.requests) + } + + rootResult, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + ExpectedTag: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + }) + if err != nil || rootResult.State != releasekit.DeliveryComplete || rootResult.Tag != "v1.2.3" { + t.Fatalf("root delivery = %#v, %v", rootResult, err) + } + + nestedTag := "integrations/ckconfig/v1.2.3" + remote.tag = nestedTag + remote.noteBody = "# Fixture v1.2.3\n\nNested delivery fixture.\n" + nestedResult, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + ModuleDir: "integrations/ckconfig", + Product: "Fixture", + Version: "v1.2.3", + ExpectedTag: nestedTag, + Destination: facadeDeliveryDestination(server.URL), + }) + if err != nil || nestedResult.State != releasekit.DeliveryComplete || nestedResult.Tag != nestedTag || nestedResult.ModulePath != "example.com/ckconfig" { + t.Fatalf("nested delivery = %#v, %v", nestedResult, err) + } + if len(remote.pages) != 2 || !remote.pages["v1.2.3"] || !remote.pages[nestedTag] { + t.Fatalf("created hosted pages = %#v", remote.pages) + } +} + +func TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests(t *testing.T) { + repository := newTwoModuleDeliveryConsumer(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: filepath.Join(t.TempDir(), "root-artifacts"), + }) + if err != nil || !prebuilt.SourceVerified { + t.Fatalf("root Build() = %#v, %v", prebuilt, err) + } + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + remote.tag = "integrations/ckconfig/v1.2.3" + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + ModuleDir: "integrations/ckconfig", + Product: "Fixture", + Version: "v1.2.3", + ExpectedTag: remote.tag, + Destination: facadeDeliveryDestination(server.URL), + Prebuilt: &prebuilt, + }) + if err == nil || !strings.Contains(err.Error(), "selected tagged module") || result.Tag != remote.tag { + t.Fatalf("nested delivery with root artifacts = %#v, %v", result, err) + } + if remote.requests != 0 { + t.Fatalf("hosted requests before prebuilt identity rejection = %d", remote.requests) + } +} + +func newTwoModuleDeliveryConsumer(t *testing.T) string { + t.Helper() + repository := newTaggedDeliveryConsumer(t) + writeNestedBuildConsumer(t, repository, "integrations/ckconfig", "example.com/ckconfig", `package main +import ( + "fmt" + "os" + "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" +) +func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } } +`) + writeBuildFixture(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Fixture v1.2.3\n\nNested delivery fixture.\n") + buildGit(t, repository, "add", "--all") + buildGit(t, repository, "commit", "-m", "add nested release module") + buildGit(t, repository, "tag", "-f", "v1.2.3") + buildGit(t, repository, "tag", "integrations/ckconfig/v1.2.3") + return repository +} + func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) { repository := newTaggedDeliveryConsumer(t) writeBuildFixture(t, repository, "local-change.txt", "dirty source") @@ -255,6 +364,7 @@ func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) { repository := newTaggedDeliveryConsumer(t) + tagCommit := buildGit(t, repository, "rev-parse", "HEAD") writeBuildFixture(t, repository, "later-change.txt", "new commit") buildGit(t, repository, "add", "--all") buildGit(t, repository, "commit", "-m", "advance checkout after release tag") @@ -276,7 +386,7 @@ func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T }, Output: &progress, }) - if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit == remote.commit { + if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit == tagCommit { t.Fatalf("Deliver() = %#v, %v; want tag/HEAD mismatch", result, err) } if progress.Len() != 0 { @@ -474,7 +584,10 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) { type facadeDeliveryServer struct { t *testing.T commit string - page bool + tag string + version string + noteBody string + pages map[string]bool requests int assets map[string][]byte url string @@ -482,7 +595,7 @@ type facadeDeliveryServer struct { func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) { t.Helper() - state := &facadeDeliveryServer{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), assets: make(map[string][]byte)} + state := &facadeDeliveryServer{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), tag: "v1.2.3", version: "v1.2.3", noteBody: "# Fixture v1.2.3\n\nDelivery fixture.\n", pages: make(map[string]bool), assets: make(map[string][]byte)} server := httptest.NewServer(http.HandlerFunc(state.serveHTTP)) state.url = server.URL return server, state @@ -495,11 +608,11 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ state.t.Errorf("authorization = %q", r.Header.Get("Authorization")) } } - switch r.URL.Path { - case "/api/v1/repos/owner/example/tags/v1.2.3": - _, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit) - case "/api/v1/repos/owner/example/releases/tags/v1.2.3": - if !state.page { + switch r.URL.EscapedPath() { + case "/api/v1/repos/owner/example/tags/" + url.PathEscape(state.tag): + _, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, state.tag, state.commit) + case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(state.tag): + if !state.pages[state.tag] { http.NotFound(w, r) return } @@ -516,10 +629,10 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ http.Error(w, "invalid payload", http.StatusBadRequest) return } - if payload.Tag != "v1.2.3" || payload.Target != state.commit || payload.Name != "Fixture v1.2.3" { + if payload.Tag != state.tag || payload.Target != state.commit || payload.Name != "Fixture "+state.version { state.t.Errorf("release payload = %#v", payload) } - state.page = true + state.pages[state.tag] = true state.writeRelease(w) case "/api/v1/repos/owner/example/releases/10/assets": if r.Method == http.MethodGet { @@ -564,9 +677,9 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 10, "html_url": state.url + "/releases/10", "tag_name": "v1.2.3", - "target_commitish": state.commit, "name": "Fixture v1.2.3", - "body": "# Fixture v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false, + "id": 10, "html_url": state.url + "/releases/10", "tag_name": state.tag, + "target_commitish": state.commit, "name": "Fixture " + state.version, + "body": state.noteBody, "draft": false, "prerelease": false, }) } diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index 7cf5dfe..acf3dea 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -7,6 +7,13 @@ The operation accepts historical lightweight and annotated tags, including a detached checkout, when the checked-out `HEAD` identifies the selected tag commit. It does not require the old remote `main` to match. +Delivery resolves the selected module's declaration and release note from the +captured commit. `Version` remains the semantic version; the full tag includes +the selected module's repository-relative physical path when it is nested. The +profile or API request selects the physical module directory. The optional API +`ExpectedTag` checks that a CI event's literal tag agrees with the derived tag +before any build or hosted request. + ## CI preparation Use `PrepareTaggedCheckout` when a disposable CI checkout must be verified @@ -66,6 +73,8 @@ The CLI reads the named token variable only for `deliver`; its default name is 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 only missing assets, and reports conflicts without replacing content. +For nested modules, the note comes from that module's committed +`docs/releases/.md`, and the hosted page is keyed by its complete tag. With no build targets, delivery is page-only: no builder, version smoke check, receipt, binary asset, or checksum manifest is involved. With selected targets, @@ -81,9 +90,10 @@ RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver --assets dist v1.2.3 The build command does not need delivery credentials. The `--assets DIR` directory must contain the private `.releasekit-build.json` receipt created by `build`. ReleaseKit checks the receipt's repository, commit, version, resolved -build settings, and file hashes before making a remote request; caller-owned -files remain available for retries. Ordinary `deliver` does not download -application dependencies or change the checkout. +module directory and path, full tag, build settings, and file hashes before +making a remote request; caller-owned files remain available for retries. +Ordinary `deliver` does not download application dependencies or change the +checkout. The result reports `complete`, `partial`, `conflict`, `unknown`, or `not_attempted`, along with matching, uploaded, pending, conflicting, and @@ -110,19 +120,23 @@ outside the checkout and removed when delivery 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`. -ReleaseKit checks that it identifies the selected repository, tagged commit, and -version, that its source was verified clean and unchanged during compilation, -and that the current files still match their recorded digests. The files remain -caller-owned, so callers can retain the original bytes for retries and are -responsible for cleanup. With neither artifact source, an empty managed target -set, or an empty prebuilt result, delivery is page-only. Do not set both `Build` -and `Prebuilt` in one request. +ReleaseKit checks its repository, module directory, declared module path, full +tag, semantic version, tagged commit, normalized settings, and artifact evidence +against the selected committed module. Binary results must record verified +source. The current files are checked against their recorded digests before any +hosted request. The files remain caller-owned, so callers can 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. Do not set both `Build` and `Prebuilt` in one +request. ```go result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ RepositoryDir: "/work/example", + ModuleDir: "integrations/example", Product: "Example", Version: "v1.2.3", + ExpectedTag: "integrations/example/v1.2.3", // optional CI event check Destination: releasekit.GiteaDestination{ BaseURL: "https://gitea.example", Owner: "example", diff --git a/docs/integrations/gitea.md b/docs/integrations/gitea.md index 6425f14..1908542 100644 --- a/docs/integrations/gitea.md +++ b/docs/integrations/gitea.md @@ -1,7 +1,7 @@ # Gitea release pages ReleaseKit's public `Deliver` operation and `deliver` command verify tagged -source, find or create its stable release page, inspect existing assets, and +source, find or create the selected module's release page, inspect existing assets, and upload missing assets through the internal Gitea client. Page-only and binary-producing profiles use the same delivery path. The [consumer delivery guide](../consumers/delivery.md) covers caller workflows; @@ -13,16 +13,19 @@ 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, a query, or a fragment. -For version `vMAJOR.MINOR.PATCH`, the client first reads +The selected module derives a complete tag from its semantic version and +physical repository-relative directory. For example, version `v1.2.3` in +`integrations/ckconfig` uses `integrations/ckconfig/v1.2.3`; a root module uses +`v1.2.3`. The client first reads `/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit SHA to equal the captured source commit. It then reads `/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 non-draft state. Its existing title is retained. ReleaseKit does not edit a -conflicting page. +conflicting page. Slash-containing tags are escaped as one URL path segment. If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases` -with the selected tag and commit, the release note's H1 as title, the complete +with the selected full tag and commit, the release note's H1 as title, the complete note as body, and draft and prerelease disabled. A create conflict, interrupted response, or malformed create response (including inconsistent decoded fields) triggers one lookup. An exact page found diff --git a/docs/internal/overview.md b/docs/internal/overview.md index bd5c123..6ece0bf 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -39,9 +39,9 @@ reports these implemented packages: | `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/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. | -| `internal/delivery` | Tagged-source and release-note checks, 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/git` | Git command boundary, committed-tree reads, 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, hosted release pages, asset inspection, manifests, and uploads. | +| `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/git` | Git command boundary, committed-tree reads, checkout-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/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/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. | @@ -88,7 +88,7 @@ not a separate release matrix: | 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` | | 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`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | +| 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` | | Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` | | Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` | | CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5f6fd8f..f7d9790 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -459,6 +459,17 @@ separate pages. Cover nested managed builds and reject an API prebuilt result for another module before hosted requests. Reuse established tampering, conflict, partial/unknown, historical-tag, and cleanup tests. Run delivery/Gitea tests. +**Completed:** Delivery now captures checkout identity once, derives the +selected module's committed tag and note path, compares an optional expected +full tag, and returns module evidence through local and hosted failures. Managed +and prebuilt binary results bind to the same repository, module, version, tag, +commit, settings, and source verification; the CLI carries that identity through +its existing private receipt. Gitea uses the literal full tag and escapes +slashes in hosted routes. Verified two module pages at one commit, nested notes +and slash tags, mismatched expected tags and prebuilt identities, preserved +delivery recovery cases, `go test ./...`, `go run ./internal/apicheck`, and +`git diff --check`. + ## Stage 9: Replace receipts with the module-bound schema **Outcome:** CLI prebuilt delivery uses one current receipt format and one diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 28048ea..30fd67f 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -13,6 +13,7 @@ import ( "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" @@ -33,6 +34,7 @@ type Request struct { ModuleDir string Product string Version string + ExpectedTag string Destination gitea.Destination Build *internalbuild.Spec Prebuilt *internalbuild.Result @@ -69,36 +71,51 @@ func Deliver(ctx context.Context, request Request) (Result, error) { if err != nil { return result, fmt.Errorf("resolve repository directory: %w", err) } - source, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version) + result.RepositoryDir = repositoryDir + selection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir) if err != nil { - return result, fmt.Errorf("inspect tagged source: %w", err) + return result, fmt.Errorf("resolve delivery module: %w", err) } - result.CandidateCommit = source.TagCommit - if source.Status != "" { - return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status) - } - if source.Head != source.TagCommit { - return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, request.Version, source.TagCommit) - } - notePath := "docs/releases/" + request.Version + ".md" - note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath) + result.ModuleDir = selection.ModuleDir + commit, err := git.CaptureHead(ctx, repositoryDir) if err != nil { return result, err } + result.CandidateCommit = commit + identity, err := releaseidentity.FromCommittedSelection(ctx, selection, request.Version, commit) + if err != nil { + return result, fmt.Errorf("resolve committed delivery identity: %w", err) + } + result.RepositoryDir = identity.RepositoryDir + result.ModuleDir = identity.ModuleDir + result.ModulePath = identity.ModulePath + result.Tag = identity.Tag + if request.ExpectedTag != "" && request.ExpectedTag != identity.Tag { + return result, fmt.Errorf("expected delivery tag %q does not match selected module tag %q", request.ExpectedTag, identity.Tag) + } + source, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag) + if err != nil { + return result, fmt.Errorf("inspect tagged source: %w", err) + } + if source.Status != "" { + return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status) + } + if source.Head != commit { + return result, fmt.Errorf("checkout HEAD changed from captured commit %s to %s", commit, source.Head) + } + if source.TagCommit != commit { + return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, identity.Tag, source.TagCommit) + } + note, err := git.ReadCommitFile(ctx, repositoryDir, commit, identity.NotePath) + if err != nil { + return result, fmt.Errorf("read release note %q: %w", identity.NotePath, err) + } if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil { - return result, fmt.Errorf("release note %q: %w", notePath, err) + return result, fmt.Errorf("release note %q: %w", identity.NotePath, err) } build := request.Prebuilt if request.Build != nil && len(request.Build.Targets) != 0 { - selection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir) - if err != nil { - return result, fmt.Errorf("resolve managed build module: %w", err) - } - identity, err := releaseidentity.FromCommittedSelection(ctx, selection, request.Version, source.TagCommit) - if err != nil { - return result, fmt.Errorf("resolve managed build identity: %w", err) - } temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-delivery-build-*") if err != nil { return result, fmt.Errorf("create temporary delivery build directory: %w", err) @@ -109,6 +126,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) { Spec: *request.Build, OutputDir: filepath.Join(temporaryDir, "assets"), Output: request.Output, + ToolOwner: gotool.NewOwner(identity.ModuleDir), }) if err != nil { return result, fmt.Errorf("build delivery assets: %w", err) @@ -122,7 +140,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) { return result, err } } - if err := validateBuild(repositoryDir, source.TagCommit, request.Version, build, assets); err != nil { + if err := validateBuild(identity, commit, build, assets); err != nil { return result, err } if len(assets) > 0 { @@ -131,22 +149,22 @@ func Deliver(ctx context.Context, request Request) (Result, error) { } } if build != nil && len(build.Settings.Targets) != 0 { - current, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version) + current, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag) if err != nil { return result, fmt.Errorf("recheck tagged source after build: %w", err) } if current.Status != "" { return result, fmt.Errorf("tagged checkout changed during build:\n%s", current.Status) } - if current.Head != source.TagCommit || current.TagCommit != source.TagCommit { + if current.Head != commit || current.TagCommit != commit { return result, errors.New("tagged source changed during build") } } releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{ Destination: request.Destination, - Version: request.Version, - Commit: source.TagCommit, + Tag: identity.Tag, + Commit: commit, Note: string(note), }) if err != nil { @@ -180,13 +198,24 @@ func Deliver(ctx context.Context, request Request) (Result, error) { return result, uploadErr } -func validateBuild(repositoryDir, commit, version string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error { +func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error { if build == nil { if len(assets) != 0 { return errors.New("assets require a build identity") } return nil } + buildRepository, err := filepath.Abs(build.RepositoryDir) + if err != nil { + return fmt.Errorf("resolve build repository: %w", err) + } + buildModule, err := filepath.Abs(build.ModuleDir) + if err != nil { + return fmt.Errorf("resolve build module: %w", err) + } + if buildRepository != identity.RepositoryDir || buildModule != identity.ModuleDir || build.ModulePath != identity.ModulePath || build.Tag != identity.Tag || build.CandidateCommit != commit || build.Version != identity.Version { + return errors.New("build result does not identify the selected tagged module") + } if len(build.Settings.Targets) == 0 { if len(assets) != 0 { return errors.New("page-only delivery cannot include binary assets") @@ -196,13 +225,6 @@ func validateBuild(repositoryDir, commit, version string, build *internalbuild.R if !build.SourceVerified { return errors.New("build source was not verified as clean and unchanged during compilation") } - buildRepository, err := filepath.Abs(build.RepositoryDir) - if err != nil { - return fmt.Errorf("resolve build repository: %w", err) - } - if buildRepository != repositoryDir || build.CandidateCommit != commit || build.Version != version { - return errors.New("build result does not identify the selected tagged source") - } 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 { return errors.New("build result does not contain the selected artifacts and complete checksum manifest") } diff --git a/internal/git/source.go b/internal/git/source.go index 2545d40..65729bd 100644 --- a/internal/git/source.go +++ b/internal/git/source.go @@ -30,6 +30,20 @@ type TaggedSource struct { TagObjectType string } +// CaptureHead records the checkout commit before identity derived from files +// in that commit is inspected. +func CaptureHead(ctx context.Context, repositoryDir string) (string, error) { + head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return "", fmt.Errorf("read checkout HEAD: %w", err) + } + head = strings.TrimSpace(head) + if !validObjectID(head) { + return "", fmt.Errorf("Git returned an invalid checkout commit %q", head) + } + return head, nil +} + // InspectTaggedSource reads the local HEAD, worktree state, and selected tag. // Both lightweight historical tags and annotated tags are supported. func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) { diff --git a/internal/gitea/release.go b/internal/gitea/release.go index 9ce0376..2d955df 100644 --- a/internal/gitea/release.go +++ b/internal/gitea/release.go @@ -11,8 +11,6 @@ import ( "net/http" "net/url" "strings" - - "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" ) type FailureKind string @@ -59,7 +57,7 @@ type Destination struct { type Request struct { Destination Destination - Version string + Tag string Commit string Note string } @@ -122,17 +120,17 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err } client := c.httpClient() apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository) - tagURL := apiRoot + "/tags/" + url.PathEscape(request.Version) + tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag) var tag tagResponse if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil { return Result{}, err } if tag.Commit.SHA != request.Commit { - return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Version, tag.Commit.SHA, request.Commit)) + return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit)) } - releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Version) + releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag) existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL) if err == nil { return acceptExisting(existing, request) @@ -143,7 +141,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err title := noteTitle(request.Note) body, err := json.Marshal(createReleaseRequest{ - TagName: request.Version, TargetCommitish: request.Commit, + TagName: request.Tag, TargetCommitish: request.Commit, Name: title, Body: request.Note, }) if err != nil { @@ -272,8 +270,8 @@ func (c Client) httpClient() *http.Client { } func validateRequest(request Request) error { - if !versioncontract.ValidStableVersion(request.Version) { - return failure(FailureInvalid, "validate request", 0, errors.New("stable version is required")) + if strings.TrimSpace(request.Tag) == "" { + return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required")) } if !validObjectID(request.Commit) { return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID")) @@ -305,14 +303,14 @@ func parseBaseURL(raw string) (*url.URL, error) { } func acceptExisting(release Release, request Request) (Result, error) { - if release.TagName != request.Version || release.Body != request.Note || release.Draft || release.Prerelease { + if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease { return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag")) } return Result{Release: release}, nil } func acceptCreated(release Release, request Request) (Result, error) { - if release.ID <= 0 || release.TagName != request.Version || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease { + if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease { return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release")) } return Result{Release: release, Created: true}, nil diff --git a/internal/gitea/release_test.go b/internal/gitea/release_test.go index ef7ab8b..998535a 100644 --- a/internal/gitea/release_test.go +++ b/internal/gitea/release_test.go @@ -26,6 +26,45 @@ func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) { } } +func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) { + tag := "integrations/ckconfig/v1.2.3" + state := &fixtureState{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + switch r.URL.EscapedPath() { + case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + writeTag(t, w, testCommit) + case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + http.NotFound(w, r) + case "/api/v1/repos/owner/project/releases": + assertRequest(t, r, http.MethodPost, r.URL.Path) + state.posts++ + var payload createReleaseRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote { + t.Fatalf("create payload = %#v", payload) + } + if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil { + t.Fatal(err) + } + default: + t.Fatalf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath()) + } + })) + defer server.Close() + + request := testRequest(server.URL) + request.Tag = tag + result, err := testClient(server).EnsureRelease(context.Background(), request) + if err != nil || !result.Created || state.posts != 1 { + t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts, err) + } +} + func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) { server, _ := newReleaseServer(t, releaseFixture{}, true) result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) @@ -324,7 +363,7 @@ func testClient(server *httptest.Server) Client { } func testRequest(baseURL string) Request { - return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Version: "v1.2.3", Commit: testCommit, Note: testNote} + return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote} } func failureKind(t *testing.T, err error) FailureKind {