Deliver module releases to full-tag pages
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
@@ -33,6 +34,7 @@ type Request struct {
|
||||
ModuleDir string
|
||||
Product string
|
||||
Version string
|
||||
ExpectedTag string
|
||||
Destination gitea.Destination
|
||||
Build *internalbuild.Spec
|
||||
Prebuilt *internalbuild.Result
|
||||
@@ -69,36 +71,51 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
||||
}
|
||||
source, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
|
||||
result.RepositoryDir = repositoryDir
|
||||
selection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("inspect tagged source: %w", err)
|
||||
return result, fmt.Errorf("resolve delivery module: %w", err)
|
||||
}
|
||||
result.CandidateCommit = source.TagCommit
|
||||
if source.Status != "" {
|
||||
return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
|
||||
}
|
||||
if source.Head != source.TagCommit {
|
||||
return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, request.Version, source.TagCommit)
|
||||
}
|
||||
notePath := "docs/releases/" + request.Version + ".md"
|
||||
note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath)
|
||||
result.ModuleDir = selection.ModuleDir
|
||||
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.CandidateCommit = commit
|
||||
identity, err := releaseidentity.FromCommittedSelection(ctx, selection, request.Version, commit)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve committed delivery identity: %w", err)
|
||||
}
|
||||
result.RepositoryDir = identity.RepositoryDir
|
||||
result.ModuleDir = identity.ModuleDir
|
||||
result.ModulePath = identity.ModulePath
|
||||
result.Tag = identity.Tag
|
||||
if request.ExpectedTag != "" && request.ExpectedTag != identity.Tag {
|
||||
return result, fmt.Errorf("expected delivery tag %q does not match selected module tag %q", request.ExpectedTag, identity.Tag)
|
||||
}
|
||||
source, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("inspect tagged source: %w", err)
|
||||
}
|
||||
if source.Status != "" {
|
||||
return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
|
||||
}
|
||||
if source.Head != commit {
|
||||
return result, fmt.Errorf("checkout HEAD changed from captured commit %s to %s", commit, source.Head)
|
||||
}
|
||||
if source.TagCommit != commit {
|
||||
return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, identity.Tag, source.TagCommit)
|
||||
}
|
||||
note, err := git.ReadCommitFile(ctx, repositoryDir, commit, identity.NotePath)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read release note %q: %w", identity.NotePath, err)
|
||||
}
|
||||
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
|
||||
return result, fmt.Errorf("release note %q: %w", notePath, err)
|
||||
return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
||||
}
|
||||
|
||||
build := request.Prebuilt
|
||||
if request.Build != nil && len(request.Build.Targets) != 0 {
|
||||
selection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve managed build module: %w", err)
|
||||
}
|
||||
identity, err := releaseidentity.FromCommittedSelection(ctx, selection, request.Version, source.TagCommit)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve managed build identity: %w", err)
|
||||
}
|
||||
temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-delivery-build-*")
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
|
||||
@@ -109,6 +126,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
Spec: *request.Build,
|
||||
OutputDir: filepath.Join(temporaryDir, "assets"),
|
||||
Output: request.Output,
|
||||
ToolOwner: gotool.NewOwner(identity.ModuleDir),
|
||||
})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("build delivery assets: %w", err)
|
||||
@@ -122,7 +140,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := validateBuild(repositoryDir, source.TagCommit, request.Version, build, assets); err != nil {
|
||||
if err := validateBuild(identity, commit, build, assets); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if len(assets) > 0 {
|
||||
@@ -131,22 +149,22 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
}
|
||||
if build != nil && len(build.Settings.Targets) != 0 {
|
||||
current, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
|
||||
current, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("recheck tagged source after build: %w", err)
|
||||
}
|
||||
if current.Status != "" {
|
||||
return result, fmt.Errorf("tagged checkout changed during build:\n%s", current.Status)
|
||||
}
|
||||
if current.Head != source.TagCommit || current.TagCommit != source.TagCommit {
|
||||
if current.Head != commit || current.TagCommit != commit {
|
||||
return result, errors.New("tagged source changed during build")
|
||||
}
|
||||
}
|
||||
|
||||
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
|
||||
Destination: request.Destination,
|
||||
Version: request.Version,
|
||||
Commit: source.TagCommit,
|
||||
Tag: identity.Tag,
|
||||
Commit: commit,
|
||||
Note: string(note),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -180,13 +198,24 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
return result, uploadErr
|
||||
}
|
||||
|
||||
func validateBuild(repositoryDir, commit, version string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error {
|
||||
func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error {
|
||||
if build == nil {
|
||||
if len(assets) != 0 {
|
||||
return errors.New("assets require a build identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve build repository: %w", err)
|
||||
}
|
||||
buildModule, err := filepath.Abs(build.ModuleDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve build module: %w", err)
|
||||
}
|
||||
if buildRepository != identity.RepositoryDir || buildModule != identity.ModuleDir || build.ModulePath != identity.ModulePath || build.Tag != identity.Tag || build.CandidateCommit != commit || build.Version != identity.Version {
|
||||
return errors.New("build result does not identify the selected tagged module")
|
||||
}
|
||||
if len(build.Settings.Targets) == 0 {
|
||||
if len(assets) != 0 {
|
||||
return errors.New("page-only delivery cannot include binary assets")
|
||||
@@ -196,13 +225,6 @@ func validateBuild(repositoryDir, commit, version string, build *internalbuild.R
|
||||
if !build.SourceVerified {
|
||||
return errors.New("build source was not verified as clean and unchanged during compilation")
|
||||
}
|
||||
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve build repository: %w", err)
|
||||
}
|
||||
if buildRepository != repositoryDir || build.CandidateCommit != commit || build.Version != version {
|
||||
return errors.New("build result does not identify the selected tagged source")
|
||||
}
|
||||
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
|
||||
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
|
||||
}
|
||||
|
||||
@@ -30,6 +30,20 @@ type TaggedSource struct {
|
||||
TagObjectType string
|
||||
}
|
||||
|
||||
// CaptureHead records the checkout commit before identity derived from files
|
||||
// in that commit is inspected.
|
||||
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
|
||||
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read checkout HEAD: %w", err)
|
||||
}
|
||||
head = strings.TrimSpace(head)
|
||||
if !validObjectID(head) {
|
||||
return "", fmt.Errorf("Git returned an invalid checkout commit %q", head)
|
||||
}
|
||||
return head, nil
|
||||
}
|
||||
|
||||
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
|
||||
// Both lightweight historical tags and annotated tags are supported.
|
||||
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
)
|
||||
|
||||
type FailureKind string
|
||||
@@ -59,7 +57,7 @@ type Destination struct {
|
||||
|
||||
type Request struct {
|
||||
Destination Destination
|
||||
Version string
|
||||
Tag string
|
||||
Commit string
|
||||
Note string
|
||||
}
|
||||
@@ -122,17 +120,17 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
|
||||
}
|
||||
client := c.httpClient()
|
||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
|
||||
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Version)
|
||||
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag)
|
||||
|
||||
var tag tagResponse
|
||||
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if tag.Commit.SHA != request.Commit {
|
||||
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Version, tag.Commit.SHA, request.Commit))
|
||||
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit))
|
||||
}
|
||||
|
||||
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Version)
|
||||
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag)
|
||||
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
|
||||
if err == nil {
|
||||
return acceptExisting(existing, request)
|
||||
@@ -143,7 +141,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
|
||||
|
||||
title := noteTitle(request.Note)
|
||||
body, err := json.Marshal(createReleaseRequest{
|
||||
TagName: request.Version, TargetCommitish: request.Commit,
|
||||
TagName: request.Tag, TargetCommitish: request.Commit,
|
||||
Name: title, Body: request.Note,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -272,8 +270,8 @@ func (c Client) httpClient() *http.Client {
|
||||
}
|
||||
|
||||
func validateRequest(request Request) error {
|
||||
if !versioncontract.ValidStableVersion(request.Version) {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("stable version is required"))
|
||||
if strings.TrimSpace(request.Tag) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
|
||||
}
|
||||
if !validObjectID(request.Commit) {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
|
||||
@@ -305,14 +303,14 @@ func parseBaseURL(raw string) (*url.URL, error) {
|
||||
}
|
||||
|
||||
func acceptExisting(release Release, request Request) (Result, error) {
|
||||
if release.TagName != request.Version || release.Body != request.Note || release.Draft || release.Prerelease {
|
||||
if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease {
|
||||
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
|
||||
}
|
||||
return Result{Release: release}, nil
|
||||
}
|
||||
|
||||
func acceptCreated(release Release, request Request) (Result, error) {
|
||||
if release.ID <= 0 || release.TagName != request.Version || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
||||
if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
|
||||
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
|
||||
}
|
||||
return Result{Release: release, Created: true}, nil
|
||||
|
||||
@@ -26,6 +26,45 @@ func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) {
|
||||
tag := "integrations/ckconfig/v1.2.3"
|
||||
state := &fixtureState{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assertAuth(t, r)
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
writeTag(t, w, testCommit)
|
||||
case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3":
|
||||
assertRequest(t, r, http.MethodGet, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
case "/api/v1/repos/owner/project/releases":
|
||||
assertRequest(t, r, http.MethodPost, r.URL.Path)
|
||||
state.posts++
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
|
||||
t.Fatalf("create payload = %#v", payload)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
request := testRequest(server.URL)
|
||||
request.Tag = tag
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), request)
|
||||
if err != nil || !result.Created || state.posts != 1 {
|
||||
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
|
||||
server, _ := newReleaseServer(t, releaseFixture{}, true)
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
@@ -324,7 +363,7 @@ func testClient(server *httptest.Server) Client {
|
||||
}
|
||||
|
||||
func testRequest(baseURL string) Request {
|
||||
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Version: "v1.2.3", Commit: testCommit, Note: testNote}
|
||||
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote}
|
||||
}
|
||||
|
||||
func failureKind(t *testing.T, err error) FailureKind {
|
||||
|
||||
Reference in New Issue
Block a user