Deliver module releases to full-tag pages
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[:])}},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
141
delivery_test.go
141
delivery_test.go
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user