Prepare ReleaseKit hosted-page delivery
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -107,6 +107,7 @@ $RECYCLE.BIN/
|
|||||||
.history/
|
.history/
|
||||||
|
|
||||||
# ReleaseKit build output
|
# ReleaseKit build output
|
||||||
|
/dist/
|
||||||
/.releasekit/
|
/.releasekit/
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
|
|||||||
|
|
||||||
type deliveryServerState struct {
|
type deliveryServerState struct {
|
||||||
commit string
|
commit string
|
||||||
|
product string
|
||||||
remoteCommit string
|
remoteCommit string
|
||||||
apiRequests int
|
apiRequests int
|
||||||
pageExists bool
|
pageExists bool
|
||||||
@@ -537,7 +538,11 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
|||||||
|
|
||||||
func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *deliveryServerState) {
|
func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *deliveryServerState) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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) {
|
func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) {
|
||||||
|
|||||||
129
cmd/releasekit/releasekit_delivery_workflow_test.go
Normal file
129
cmd/releasekit/releasekit_delivery_workflow_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
before declaring success or retrying; successful pushes do not require a
|
||||||
separate remote read-back gate.
|
separate remote read-back gate.
|
||||||
|
|
||||||
ReleaseKit's initial deliverable is source only. Distributing executable builds
|
ReleaseKit's deliverables are its immutable source tag and a hosted Gitea
|
||||||
requires a deliberate update identifying the deliverables, selected targets,
|
release page whose body is the complete checked-in release note. A prepared
|
||||||
and automation responsibilities. Supporting cross-builds for consumers does not
|
Woodpecker workflow builds the local release tool from that tagged source and
|
||||||
declare ReleaseKit binaries. Additional declared deliverables may be published
|
delivers the note page. This adds no executable download or binary manifest.
|
||||||
asynchronously from the tagged source, must be traceable to that source, and
|
Any later distribution of executable builds requires a deliberate update
|
||||||
must identify the same version.
|
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
|
Track source-release completion and artifact delivery separately. Report delivery
|
||||||
failures and optional investigations under artifact-delivery status.
|
failures and optional investigations under artifact-delivery status.
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# ReleaseKit release procedure
|
# ReleaseKit release procedure
|
||||||
|
|
||||||
ReleaseKit's initial deliverable is source only. The authoritative repository
|
ReleaseKit's deliverables are its source tag and a hosted Gitea page containing
|
||||||
is `origin` at `https://gitea.maximumdirect.net/eric/ReleaseKit.git`, and the
|
the complete checked-in release note. The page is delivered asynchronously by
|
||||||
release branch is `main`. A release completes when the intended annotated tag
|
the prepared [Woodpecker workflow](../examples/woodpecker/releasekit-source-delivery.yml);
|
||||||
is successfully pushed, or when one exact-object recovery query confirms that
|
it is not a source-release completion check and carries no binary assets. The
|
||||||
the push succeeded. CI, hosted artifacts, and consumer installation are not
|
authoritative repository is `origin` at
|
||||||
completion checks.
|
`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
|
## 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
|
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
|
published. Correct and commit the candidate, publish `main`, and run the
|
||||||
command again. ReleaseKit does not publish executable artifacts or wait for
|
command again. The prepared Woodpecker workflow builds the CLI from the tagged
|
||||||
asynchronous delivery; any future deliverables require a separate policy and
|
checkout and runs page-only delivery. To activate it after configuring the
|
||||||
procedure update.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReleaseKit staged implementation plan
|
# 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
|
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
|
## 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
|
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
|
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,
|
leaves source status unchanged. Workflow preparation requires no real tag,
|
||||||
credentials, remote push, CI activation, or maintainer checkpoint.
|
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
|
## Stage 18: Complete the initial implementation and handoff documentation
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 17.
|
Status: Pending. Depends on Stage 17.
|
||||||
|
|||||||
30
examples/woodpecker/releasekit-source-delivery.yml
Normal file
30
examples/woodpecker/releasekit-source-delivery.yml
Normal file
@@ -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}"
|
||||||
@@ -10,5 +10,11 @@
|
|||||||
"GOSUMDB": "off"
|
"GOSUMDB": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"delivery": {
|
||||||
|
"base_url": "https://gitea.maximumdirect.net",
|
||||||
|
"owner": "eric",
|
||||||
|
"repository": "ReleaseKit",
|
||||||
|
"token_env": "RELEASEKIT_GITEA_TOKEN"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user