diff --git a/.gitignore b/.gitignore index 6c4a54d..c65c739 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ $RECYCLE.BIN/ # ReleaseKit build output /dist/ +/.releasekit/ # Built Visual Studio Code Extensions *.vsix diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 0dd9878..b472498 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -423,6 +423,7 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet type deliveryServerState struct { commit string + product string remoteCommit string apiRequests int pageExists bool @@ -537,7 +538,11 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *deliveryServerState) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": "v1.2.3", "target_commitish": state.commit, "name": "Example Product v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false}) + product := state.product + if product == "" { + product = "Example Product" + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": "v1.2.3", "target_commitish": state.commit, "name": product + " v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false}) } func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) { diff --git a/cmd/releasekit/releasekit_delivery_workflow_test.go b/cmd/releasekit/releasekit_delivery_workflow_test.go new file mode 100644 index 0000000..2f4c04c --- /dev/null +++ b/cmd/releasekit/releasekit_delivery_workflow_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" +) + +func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) { + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate integration test source") + } + root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "../..")) + profile, err := cli.LoadSettings(root, "") + if err != nil { + t.Fatalf("load ReleaseKit source-only profile: %v", err) + } + if profile.Build != nil && len(profile.Build.Targets) != 0 { + t.Fatalf("ReleaseKit profile declares binary targets: %#v", profile.Build.Targets) + } + if profile.Delivery == nil || profile.Delivery.BaseURL != "https://gitea.maximumdirect.net" || profile.Delivery.Owner != "eric" || profile.Delivery.Repository != "ReleaseKit" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" { + t.Fatalf("ReleaseKit delivery profile = %#v", profile.Delivery) + } + repository := filepath.Join(t.TempDir(), "tagged ReleaseKit") + clone := exec.Command("git", "clone", "--local", "--no-hardlinks", root, repository) + if output, err := clone.CombinedOutput(); err != nil { + t.Fatalf("clone ReleaseKit source: %v\n%s", err, output) + } + cliGitRun(t, repository, "config", "user.name", "ReleaseKit Test") + cliGitRun(t, repository, "config", "user.email", "releasekit@example.test") + + const version = "v1.2.3" + note := "# ReleaseKit v1.2.3\n\nSource-only delivery fixture.\n\n" + + "[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/COPYRIGHT) |\n" + + "[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/LICENSE) |\n" + + "[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v1.2.3/docs/oss/attribution.md)\n" + notePath := filepath.Join(repository, "docs", "releases", version+".md") + if err := os.MkdirAll(filepath.Dir(notePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(notePath, []byte(note), 0o600); err != nil { + t.Fatal(err) + } + ignore, err := os.OpenFile(filepath.Join(repository, ".gitignore"), os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := ignore.WriteString("\n/.releasekit/\n"); err != nil { + t.Fatal(err) + } + if err := ignore.Close(); err != nil { + t.Fatal(err) + } + cliGitRun(t, repository, "add", "docs/releases/"+version+".md", ".gitignore") + cliGitRun(t, repository, "commit", "-m", "prepare source-only delivery") + + state := &deliveryServerState{product: "ReleaseKit"} + server := newDeliveryServer(t, state) + defer server.Close() + writeDeliveryProfile(t, repository, cli.Settings{ + Product: "ReleaseKit", + Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + }) + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", "-a", version, "-m", "ReleaseKit v1.2.3") + + remote := filepath.Join(t.TempDir(), "origin.git") + initRemote := exec.Command("git", "init", "--bare", remote) + if output, err := initRemote.CombinedOutput(); err != nil { + t.Fatalf("initialize local delivery remote: %v\n%s", err, output) + } + cliGitRun(t, repository, "remote", "set-url", "origin", remote) + cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main", "refs/tags/"+version) + + // Match the maintained workflow: fetch and bind the tag, then build the + // executable from that detached tagged checkout before page-only delivery. + cliGitRun(t, repository, "fetch", "origin", "refs/tags/"+version+":refs/tags/"+version) + if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.commit { + t.Fatalf("fetched tag commit = %s, want %s", got, state.commit) + } + cliGitRun(t, repository, "checkout", "--detach", state.commit) + tool := filepath.Join(repository, ".releasekit", "bin", "releasekit") + if err := os.MkdirAll(filepath.Dir(tool), 0o700); err != nil { + t.Fatal(err) + } + build := exec.Command("go", "build", "-o", tool, "./cmd/releasekit") + build.Dir = repository + build.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOSUMDB=off") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build CLI from tagged checkout: %v\n%s", err, output) + } + + state.remoteCommit = strings.Repeat("f", 40) + failure := exec.Command(tool, "deliver", "--repo", repository, version) + failure.Dir = repository + failure.Env = append(os.Environ(), "RELEASEKIT_GITEA_TOKEN=fixture-token") + if output, err := failure.CombinedOutput(); err == nil { + t.Fatalf("delivery with a mismatched hosted tag succeeded: %s", output) + } + if state.releaseCreates != 0 || cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}") != state.commit { + t.Fatal("delivery failure changed hosted release state or source tag") + } + state.remoteCommit = "" + + for attempt := 0; attempt < 2; attempt++ { + output := runWoodpeckerCommand(t, tool, repository, true, "deliver", "--repo", repository, version) + if !strings.Contains(output, "state complete") { + t.Fatalf("delivery attempt %d output = %q", attempt+1, output) + } + } + if state.releaseCreates != 1 || state.assetUploads != 0 || state.assetListReads != 0 { + t.Fatalf("page-only workflow calls = creates:%d uploads:%d asset reads:%d", state.releaseCreates, state.assetUploads, state.assetListReads) + } + if state.noteBody != note { + t.Fatalf("hosted note body = %q, want complete note %q", state.noteBody, note) + } + if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.commit { + t.Fatalf("source tag moved after delivery: %s, want %s", got, state.commit) + } + if status := cliGitOutput(t, repository, "status", "--porcelain"); status != "" { + t.Fatalf("tagged checkout became dirty after delivery: %s", status) + } +} diff --git a/docs/policy/release.md b/docs/policy/release.md index fe49816..bd7da91 100644 --- a/docs/policy/release.md +++ b/docs/policy/release.md @@ -41,12 +41,16 @@ A failed or ambiguous push requires resolving the authoritative remote state before declaring success or retrying; successful pushes do not require a separate remote read-back gate. -ReleaseKit's initial deliverable is source only. Distributing executable builds -requires a deliberate update identifying the deliverables, selected targets, -and automation responsibilities. Supporting cross-builds for consumers does not -declare ReleaseKit binaries. Additional declared deliverables may be published -asynchronously from the tagged source, must be traceable to that source, and -must identify the same version. +ReleaseKit's deliverables are its immutable source tag and a hosted Gitea +release page whose body is the complete checked-in release note. A prepared +Woodpecker workflow builds the local release tool from that tagged source and +delivers the note page. This adds no executable download or binary manifest. +Any later distribution of executable builds requires a deliberate update +identifying the deliverables, selected targets, and automation responsibilities. +Supporting cross-builds for consumers does not declare ReleaseKit binaries. +Additional declared deliverables may be published asynchronously from the +tagged source, must be traceable to that source, and must identify the same +version. Track source-release completion and artifact delivery separately. Report delivery failures and optional investigations under artifact-delivery status. diff --git a/docs/release.md b/docs/release.md index 07ff6ce..f765532 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,11 +1,14 @@ # ReleaseKit release procedure -ReleaseKit's initial deliverable is source only. The authoritative repository -is `origin` at `https://gitea.maximumdirect.net/eric/ReleaseKit.git`, and the -release branch is `main`. A release completes when the intended annotated tag -is successfully pushed, or when one exact-object recovery query confirms that -the push succeeded. CI, hosted artifacts, and consumer installation are not -completion checks. +ReleaseKit's deliverables are its source tag and a hosted Gitea page containing +the complete checked-in release note. The page is delivered asynchronously by +the prepared [Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml); +it is not a source-release completion check and carries no binary assets. The +authoritative repository is `origin` at +`https://gitea.maximumdirect.net/eric/ReleaseKit.git`, and the release branch +is `main`. A release completes when the intended annotated tag is successfully +pushed, or when one exact-object recovery query confirms that the push +succeeded. CI and consumer installation are not completion checks. ## Prepare a candidate @@ -88,6 +91,24 @@ uses the version, choose a new version after resolving the conflict. If validation or a guard fails before the push, the local tag has not been published. Correct and commit the candidate, publish `main`, and run the -command again. ReleaseKit does not publish executable artifacts or wait for -asynchronous delivery; any future deliverables require a separate policy and -procedure update. +command again. The prepared Woodpecker workflow builds the CLI from the tagged +checkout and runs page-only delivery. To activate it after configuring the +destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml` +and add the Woodpecker repository secret `releasekit_gitea_token`. If page +delivery fails or is uncertain, inspect the hosted release and retry the same +immutable version from its tag: + +```sh +git fetch origin "refs/tags/vX.Y.Z:refs/tags/vX.Y.Z" +git checkout --detach "vX.Y.Z" +mkdir -p .releasekit/bin +GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit +RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" .releasekit/bin/releasekit deliver "vX.Y.Z" +``` + +The root ignore file excludes `.releasekit/`. Delivery uses the committed note +and does not build consumer binaries, create a checksum manifest, or require a +previously released ReleaseKit executable. Keep source publication and hosted +page delivery status separate; retry only after resolving an uncertain remote +write. The workflow secret is named `releasekit_gitea_token` and is mapped to +`RELEASEKIT_GITEA_TOKEN` only in the delivery step. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cf73db8..019326a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit staged implementation plan -Status: In progress. Stages 1–16 are complete; later stages remain pending. +Status: In progress. Stages 1–17 are complete; later stages remain pending. Date: 2026-10-05 @@ -882,7 +882,7 @@ Woodpecker runner, image pull, secret, or hosted delivery service was used. ## Stage 17: Prepare ReleaseKit's own hosted-page delivery -Status: Pending. Depends on Stage 16. +Status: Complete. Depends on Stage 16. 1. Extend ReleaseKit's source-only profile with its Gitea destination and token environment name. Keep the build target set empty and retain its selected @@ -905,6 +905,19 @@ requiring a receipt, or consulting a previously released tool. Delivery failure leaves source status unchanged. Workflow preparation requires no real tag, credentials, remote push, CI activation, or maintainer checkpoint. +Evidence: ReleaseKit's root profile identifies its Gitea destination and +`RELEASEKIT_GITEA_TOKEN`, preserves the offline default test suite, and declares +no build targets. The repository-specific Woodpecker example remains under +`examples/` and builds the CLI from the fetched, commit-checked tag before +page-only delivery; the release procedure documents later activation and secret +configuration, source-tag completion, and same-tag retry. A local integration +test clones and tags a temporary copy of the repository, builds the CLI from +that tag, and exercises delivery against a local HTTP server. It verifies a +hosted-tag failure leaves the source tag intact, then checks page creation and +an identical rerun without assets; the complete note body, including its three +license links, is preserved. No real tag, push, credential, or hosted service +was used. + ## Stage 18: Complete the initial implementation and handoff documentation Status: Pending. Depends on Stage 17. diff --git a/examples/woodpecker/releasekit-source-delivery.yml b/examples/woodpecker/releasekit-source-delivery.yml new file mode 100644 index 0000000..faf318b --- /dev/null +++ b/examples/woodpecker/releasekit-source-delivery.yml @@ -0,0 +1,30 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: prepare + image: golang:1.25.0-bookworm + commands: + - | + test -n "$${CI_COMMIT_TAG}" + test -n "$${CI_COMMIT_SHA}" + git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}" + test "$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}")" = "$${CI_COMMIT_SHA}" + git checkout --detach "$${CI_COMMIT_SHA}" + test "$(git rev-parse HEAD)" = "$${CI_COMMIT_SHA}" + mkdir -p "$${CI_WORKSPACE}/.releasekit/bin" + + - name: build-tool + image: golang:1.25.0-bookworm + commands: + - GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o "$${CI_WORKSPACE}/.releasekit/bin/releasekit" ./cmd/releasekit + + - name: deliver-page + image: golang:1.25.0-bookworm + environment: + RELEASEKIT_GITEA_TOKEN: + from_secret: releasekit_gitea_token + commands: + - | + "$${CI_WORKSPACE}/.releasekit/bin/releasekit" deliver --repo "$${CI_WORKSPACE}" "$${CI_COMMIT_TAG}" diff --git a/releasekit.json b/releasekit.json index 83defcd..4a5b701 100644 --- a/releasekit.json +++ b/releasekit.json @@ -10,5 +10,11 @@ "GOSUMDB": "off" } } - ] + ], + "delivery": { + "base_url": "https://gitea.maximumdirect.net", + "owner": "eric", + "repository": "ReleaseKit", + "token_env": "RELEASEKIT_GITEA_TOKEN" + } }