Deliver module releases to full-tag pages

This commit is contained in:
2026-10-10 03:10:43 +00:00
parent fdce017aff
commit 926b74bc0b
14 changed files with 317 additions and 86 deletions

View File

@@ -32,6 +32,9 @@ type ReceiptArtifact struct {
// BuildReceipt is private local coordination data for generated binaries. // BuildReceipt is private local coordination data for generated binaries.
type BuildReceipt struct { type BuildReceipt struct {
RepositoryDir string `json:"repository_dir"` RepositoryDir string `json:"repository_dir"`
ModuleDir string `json:"module_dir"`
ModulePath string `json:"module_path"`
Tag string `json:"tag"`
CandidateCommit string `json:"candidate_commit"` CandidateCommit string `json:"candidate_commit"`
SourceVerified bool `json:"source_verified"` SourceVerified bool `json:"source_verified"`
Version string `json:"version"` Version string `json:"version"`
@@ -144,8 +147,11 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if !versioncontract.ValidStableVersion(receipt.Version) { if !versioncontract.ValidStableVersion(receipt.Version) {
return fmt.Errorf("build receipt has invalid version %q", receipt.Version) return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
} }
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.OutputDir) { if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.ModuleDir) || !filepath.IsAbs(receipt.OutputDir) {
return errors.New("build receipt repository and output directories must be absolute") 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) { if !validCommitID(receipt.CandidateCommit) {
return errors.New("build receipt has invalid candidate commit") return errors.New("build receipt has invalid candidate commit")

View File

@@ -21,6 +21,9 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
digest := sha256.Sum256(contents) digest := sha256.Sum256(contents)
receipt := BuildReceipt{ receipt := BuildReceipt{
RepositoryDir: repositoryDir, RepositoryDir: repositoryDir,
ModuleDir: repositoryDir,
ModulePath: "example.com/receipt",
Tag: "v1.2.3",
CandidateCommit: strings.Repeat("a", 40), CandidateCommit: strings.Repeat("a", 40),
Version: "v1.2.3", Version: "v1.2.3",
MainPackage: "./cmd/example", MainPackage: "./cmd/example",

View File

@@ -359,7 +359,8 @@ func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult { func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult {
result := &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, Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName,
OutputDir: receipt.OutputDir, Compiler: receipt.Compiler, OutputDir: receipt.OutputDir, Compiler: receipt.Compiler,
} }
@@ -394,6 +395,9 @@ func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt { func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
receipt := cli.BuildReceipt{ receipt := cli.BuildReceipt{
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
ModuleDir: result.ModuleDir,
ModulePath: result.ModulePath,
Tag: result.Tag,
CandidateCommit: result.CandidateCommit, CandidateCommit: result.CandidateCommit,
SourceVerified: result.SourceVerified, SourceVerified: result.SourceVerified,
Version: result.Version, Version: result.Version,

View File

@@ -937,9 +937,10 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
digest := sha256.Sum256(content) digest := sha256.Sum256(content)
target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"} target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"}
receipt := cli.BuildReceipt{ receipt := cli.BuildReceipt{
RepositoryDir: resolvedRepository, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), RepositoryDir: resolvedRepository, ModuleDir: resolvedRepository, ModulePath: "example.com/cli-candidate", Tag: "v1.2.3",
SourceVerified: true, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"),
Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, SourceVerified: true,
Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName,
OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture", OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture",
Artifacts: []cli.ReceiptArtifact{{Path: artifactPath, Filename: filename, Target: target, SHA256: hex.EncodeToString(digest[:])}}, Artifacts: []cli.ReceiptArtifact{{Path: artifactPath, Filename: filename, Target: target, SHA256: hex.EncodeToString(digest[:])}},
} }

View File

@@ -48,9 +48,11 @@ type DeliveryRequest struct {
RepositoryDir string RepositoryDir string
// ModuleDir selects a repository-relative module directory; empty selects // ModuleDir selects a repository-relative module directory; empty selects
// the repository root. // the repository root.
ModuleDir string ModuleDir string
Product string Product string
Version string Version string
// ExpectedTag optionally binds delivery to the full tag that triggered a CI event.
ExpectedTag string
Destination GiteaDestination Destination GiteaDestination
// Build selects binaries to build in temporary storage outside the checkout. // Build selects binaries to build in temporary storage outside the checkout.
Build *BuildSpec Build *BuildSpec
@@ -105,6 +107,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
ModuleDir: request.ModuleDir, ModuleDir: request.ModuleDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
ExpectedTag: request.ExpectedTag,
Destination: destination, Destination: destination,
Build: internalBuildSpec(request.Build), Build: internalBuildSpec(request.Build),
Prebuilt: prebuilt, Prebuilt: prebuilt,

View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -190,6 +191,14 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
repository := newTaggedDeliveryConsumer(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() temporaryRoot := t.TempDir()
t.Setenv("TMPDIR", temporaryRoot) t.Setenv("TMPDIR", temporaryRoot)
server, remote := newFacadeDeliveryServer(t, repository) server, remote := newFacadeDeliveryServer(t, repository)
@@ -202,7 +211,7 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
Version: "v1.2.3", Version: "v1.2.3",
Destination: facadeDeliveryDestination(server.URL), Destination: facadeDeliveryDestination(server.URL),
Build: test.build, Build: test.build,
Prebuilt: test.prebuilt, Prebuilt: prebuilt,
Output: &progress, Output: &progress,
}) })
if err != nil || result.State != releasekit.DeliveryComplete { 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) { func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
writeBuildFixture(t, repository, "local-change.txt", "dirty source") writeBuildFixture(t, repository, "local-change.txt", "dirty source")
@@ -255,6 +364,7 @@ func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T)
func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) { func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
tagCommit := buildGit(t, repository, "rev-parse", "HEAD")
writeBuildFixture(t, repository, "later-change.txt", "new commit") writeBuildFixture(t, repository, "later-change.txt", "new commit")
buildGit(t, repository, "add", "--all") buildGit(t, repository, "add", "--all")
buildGit(t, repository, "commit", "-m", "advance checkout after release tag") buildGit(t, repository, "commit", "-m", "advance checkout after release tag")
@@ -276,7 +386,7 @@ func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T
}, },
Output: &progress, 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) t.Fatalf("Deliver() = %#v, %v; want tag/HEAD mismatch", result, err)
} }
if progress.Len() != 0 { if progress.Len() != 0 {
@@ -474,7 +584,10 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
type facadeDeliveryServer struct { type facadeDeliveryServer struct {
t *testing.T t *testing.T
commit string commit string
page bool tag string
version string
noteBody string
pages map[string]bool
requests int requests int
assets map[string][]byte assets map[string][]byte
url string url string
@@ -482,7 +595,7 @@ type facadeDeliveryServer struct {
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) { func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
t.Helper() 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)) server := httptest.NewServer(http.HandlerFunc(state.serveHTTP))
state.url = server.URL state.url = server.URL
return server, state 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")) state.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
} }
} }
switch r.URL.Path { switch r.URL.EscapedPath() {
case "/api/v1/repos/owner/example/tags/v1.2.3": case "/api/v1/repos/owner/example/tags/" + url.PathEscape(state.tag):
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit) _, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, state.tag, state.commit)
case "/api/v1/repos/owner/example/releases/tags/v1.2.3": case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(state.tag):
if !state.page { if !state.pages[state.tag] {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -516,10 +629,10 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
http.Error(w, "invalid payload", http.StatusBadRequest) http.Error(w, "invalid payload", http.StatusBadRequest)
return 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.t.Errorf("release payload = %#v", payload)
} }
state.page = true state.pages[state.tag] = true
state.writeRelease(w) state.writeRelease(w)
case "/api/v1/repos/owner/example/releases/10/assets": case "/api/v1/repos/owner/example/releases/10/assets":
if r.Method == http.MethodGet { 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) { func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"id": 10, "html_url": state.url + "/releases/10", "tag_name": "v1.2.3", "id": 10, "html_url": state.url + "/releases/10", "tag_name": state.tag,
"target_commitish": state.commit, "name": "Fixture v1.2.3", "target_commitish": state.commit, "name": "Fixture " + state.version,
"body": "# Fixture v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false, "body": state.noteBody, "draft": false, "prerelease": false,
}) })
} }

View File

@@ -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 detached checkout, when the checked-out `HEAD` identifies the selected tag
commit. It does not require the old remote `main` to match. 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 ## CI preparation
Use `PrepareTaggedCheckout` when a disposable CI checkout must be verified 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 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 hosted body. A retry accepts an identical page and matching attachments, adds
only missing assets, and reports conflicts without replacing content. only missing assets, and reports conflicts without replacing content.
For nested modules, the note comes from that module's committed
`docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
With no build targets, delivery is page-only: no builder, version smoke check, With no build targets, delivery is page-only: no builder, version smoke check,
receipt, binary asset, or checksum manifest is involved. With selected targets, 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` The build command does not need delivery credentials. The `--assets DIR`
directory must contain the private `.releasekit-build.json` receipt created by directory must contain the private `.releasekit-build.json` receipt created by
`build`. ReleaseKit checks the receipt's repository, commit, version, resolved `build`. ReleaseKit checks the receipt's repository, commit, version, resolved
build settings, and file hashes before making a remote request; caller-owned module directory and path, full tag, build settings, and file hashes before
files remain available for retries. Ordinary `deliver` does not download making a remote request; caller-owned files remain available for retries.
application dependencies or change the checkout. Ordinary `deliver` does not download application dependencies or change the
checkout.
The result reports `complete`, `partial`, `conflict`, `unknown`, or The result reports `complete`, `partial`, `conflict`, `unknown`, or
`not_attempted`, along with matching, uploaded, pending, conflicting, and `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. progress. With no build, or with an empty target set, delivery is page-only.
To deliver binaries built separately, set `Prebuilt` to the `BuildResult`. To deliver binaries built separately, set `Prebuilt` to the `BuildResult`.
ReleaseKit checks that it identifies the selected repository, tagged commit, and ReleaseKit checks its repository, module directory, declared module path, full
version, that its source was verified clean and unchanged during compilation, tag, semantic version, tagged commit, normalized settings, and artifact evidence
and that the current files still match their recorded digests. The files remain against the selected committed module. Binary results must record verified
caller-owned, so callers can retain the original bytes for retries and are source. The current files are checked against their recorded digests before any
responsible for cleanup. With neither artifact source, an empty managed target hosted request. The files remain caller-owned, so callers can retain the
set, or an empty prebuilt result, delivery is page-only. Do not set both `Build` original bytes for retries and are responsible for cleanup. With neither
and `Prebuilt` in one request. 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 ```go
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
RepositoryDir: "/work/example", RepositoryDir: "/work/example",
ModuleDir: "integrations/example",
Product: "Example", Product: "Example",
Version: "v1.2.3", Version: "v1.2.3",
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
Destination: releasekit.GiteaDestination{ Destination: releasekit.GiteaDestination{
BaseURL: "https://gitea.example", BaseURL: "https://gitea.example",
Owner: "example", Owner: "example",

View File

@@ -1,7 +1,7 @@
# Gitea release pages # Gitea release pages
ReleaseKit's public `Deliver` operation and `deliver` command verify tagged 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 upload missing assets through the internal Gitea client. Page-only and
binary-producing profiles use the same delivery path. The binary-producing profiles use the same delivery path. The
[consumer delivery guide](../consumers/delivery.md) covers caller workflows; [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, results. The base URL must use HTTP or HTTPS and cannot contain URL credentials,
a query, or a fragment. 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 `/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit
SHA to equal the captured source commit. It then reads SHA to equal the captured source commit. It then reads
`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page `/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 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 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` 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 note as body, and draft and prerelease disabled. A create conflict, interrupted
response, or malformed create response (including inconsistent decoded fields) response, or malformed create response (including inconsistent decoded fields)
triggers one lookup. An exact page found triggers one lookup. An exact page found

View File

@@ -39,9 +39,9 @@ reports these implemented packages:
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. | | `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/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/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/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, 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/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, hosted release pages, asset inspection, manifests, and uploads. | | `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/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/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. | | `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` | | 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` | | 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` | | 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` | | 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` | | 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` | | CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` |

View File

@@ -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, for another module before hosted requests. Reuse established tampering, conflict,
partial/unknown, historical-tag, and cleanup tests. Run delivery/Gitea tests. 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 ## Stage 9: Replace receipts with the module-bound schema
**Outcome:** CLI prebuilt delivery uses one current receipt format and one **Outcome:** CLI prebuilt delivery uses one current receipt format and one

View File

@@ -13,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "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/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" "gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
@@ -33,6 +34,7 @@ type Request struct {
ModuleDir string ModuleDir string
Product string Product string
Version string Version string
ExpectedTag string
Destination gitea.Destination Destination gitea.Destination
Build *internalbuild.Spec Build *internalbuild.Spec
Prebuilt *internalbuild.Result Prebuilt *internalbuild.Result
@@ -69,36 +71,51 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
if err != nil { if err != nil {
return result, fmt.Errorf("resolve repository directory: %w", err) 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 { 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 result.ModuleDir = selection.ModuleDir
if source.Status != "" { commit, err := git.CaptureHead(ctx, repositoryDir)
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)
if err != nil { if err != nil {
return result, err 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 { 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 build := request.Prebuilt
if request.Build != nil && len(request.Build.Targets) != 0 { 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-*") temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-delivery-build-*")
if err != nil { if err != nil {
return result, fmt.Errorf("create temporary delivery build directory: %w", err) 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, Spec: *request.Build,
OutputDir: filepath.Join(temporaryDir, "assets"), OutputDir: filepath.Join(temporaryDir, "assets"),
Output: request.Output, Output: request.Output,
ToolOwner: gotool.NewOwner(identity.ModuleDir),
}) })
if err != nil { if err != nil {
return result, fmt.Errorf("build delivery assets: %w", err) 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 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 return result, err
} }
if len(assets) > 0 { 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 { 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 { if err != nil {
return result, fmt.Errorf("recheck tagged source after build: %w", err) return result, fmt.Errorf("recheck tagged source after build: %w", err)
} }
if current.Status != "" { if current.Status != "" {
return result, fmt.Errorf("tagged checkout changed during build:\n%s", 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") return result, errors.New("tagged source changed during build")
} }
} }
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{ releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
Destination: request.Destination, Destination: request.Destination,
Version: request.Version, Tag: identity.Tag,
Commit: source.TagCommit, Commit: commit,
Note: string(note), Note: string(note),
}) })
if err != nil { if err != nil {
@@ -180,13 +198,24 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
return result, uploadErr 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 build == nil {
if len(assets) != 0 { if len(assets) != 0 {
return errors.New("assets require a build identity") return errors.New("assets require a build identity")
} }
return nil 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(build.Settings.Targets) == 0 {
if len(assets) != 0 { if len(assets) != 0 {
return errors.New("page-only delivery cannot include binary assets") 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 { if !build.SourceVerified {
return errors.New("build source was not verified as clean and unchanged during compilation") 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 { 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") return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
} }

View File

@@ -30,6 +30,20 @@ type TaggedSource struct {
TagObjectType string 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. // InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
// Both lightweight historical tags and annotated tags are supported. // Both lightweight historical tags and annotated tags are supported.
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) { func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {

View File

@@ -11,8 +11,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
type FailureKind string type FailureKind string
@@ -59,7 +57,7 @@ type Destination struct {
type Request struct { type Request struct {
Destination Destination Destination Destination
Version string Tag string
Commit string Commit string
Note string Note string
} }
@@ -122,17 +120,17 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
} }
client := c.httpClient() client := c.httpClient()
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository) 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 var tag tagResponse
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil { if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
return Result{}, err return Result{}, err
} }
if tag.Commit.SHA != request.Commit { 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) existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
if err == nil { if err == nil {
return acceptExisting(existing, request) return acceptExisting(existing, request)
@@ -143,7 +141,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
title := noteTitle(request.Note) title := noteTitle(request.Note)
body, err := json.Marshal(createReleaseRequest{ body, err := json.Marshal(createReleaseRequest{
TagName: request.Version, TargetCommitish: request.Commit, TagName: request.Tag, TargetCommitish: request.Commit,
Name: title, Body: request.Note, Name: title, Body: request.Note,
}) })
if err != nil { if err != nil {
@@ -272,8 +270,8 @@ func (c Client) httpClient() *http.Client {
} }
func validateRequest(request Request) error { func validateRequest(request Request) error {
if !versioncontract.ValidStableVersion(request.Version) { if strings.TrimSpace(request.Tag) == "" {
return failure(FailureInvalid, "validate request", 0, errors.New("stable version is required")) return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
} }
if !validObjectID(request.Commit) { if !validObjectID(request.Commit) {
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID")) 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) { 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{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
} }
return Result{Release: release}, nil return Result{Release: release}, nil
} }
func acceptCreated(release Release, request Request) (Result, error) { 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{}, 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 return Result{Release: release, Created: true}, nil

View File

@@ -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) { func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
server, _ := newReleaseServer(t, releaseFixture{}, true) server, _ := newReleaseServer(t, releaseFixture{}, true)
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) 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 { 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 { func failureKind(t *testing.T, err error) FailureKind {