Add shared CI delivery command
This commit is contained in:
12
README.md
12
README.md
@@ -3,10 +3,10 @@
|
|||||||
ReleaseKit is an opinionated Go library and executable for a shared release
|
ReleaseKit is an opinionated Go library and executable for a shared release
|
||||||
process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
|
process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
|
||||||
local candidate check, source-tag publication API, selected-target build API,
|
local candidate check, source-tag publication API, selected-target build API,
|
||||||
tagged-source delivery API, and `version`, `check`, `publish`, `build`, and
|
tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
|
||||||
`deliver` commands are implemented. ReleaseKit's source-only maintainer
|
`deliver`, and `ci-deliver` commands are implemented. ReleaseKit's source-only
|
||||||
procedure and prepared Woodpecker workflow for hosted note-page delivery are
|
maintainer procedure and prepared Woodpecker workflow for hosted note-page
|
||||||
documented. ReleaseKit does not declare binary deliverables. The
|
delivery are documented. ReleaseKit does not declare binary deliverables. The
|
||||||
[product roadmap](docs/roadmap/releasekit.md) records remaining adoption and
|
[product roadmap](docs/roadmap/releasekit.md) records remaining adoption and
|
||||||
deployment work.
|
deployment work.
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ failure recovery.
|
|||||||
remaining release and deployment work.
|
remaining release and deployment work.
|
||||||
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
||||||
policy reading.
|
policy reading.
|
||||||
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`, and
|
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`,
|
||||||
`deliver` commands.
|
`deliver`, and `ci-deliver` commands.
|
||||||
- [Configuration](docs/config.md): profile fields and child-command settings.
|
- [Configuration](docs/config.md): profile fields and child-command settings.
|
||||||
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications
|
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications
|
||||||
read their embedded version through `buildinfo`.
|
read their embedded version through `buildinfo`.
|
||||||
|
|||||||
@@ -16,16 +16,17 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION"
|
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION"
|
||||||
|
|
||||||
type Command string
|
type Command string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VersionCommand Command = "version"
|
VersionCommand Command = "version"
|
||||||
CheckAction Command = "check"
|
CheckAction Command = "check"
|
||||||
PublishAction Command = "publish"
|
PublishAction Command = "publish"
|
||||||
BuildAction Command = "build"
|
BuildAction Command = "build"
|
||||||
DeliveryAction Command = "deliver"
|
DeliveryAction Command = "deliver"
|
||||||
|
CIDeliveryAction Command = "ci-deliver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invocation contains parsed command settings owned by the CLI adapter.
|
// Invocation contains parsed command settings owned by the CLI adapter.
|
||||||
@@ -35,6 +36,8 @@ type Invocation struct {
|
|||||||
ConfigPath string
|
ConfigPath string
|
||||||
OutputDir string
|
OutputDir string
|
||||||
AssetsDir string
|
AssetsDir string
|
||||||
|
Remote string
|
||||||
|
Commit string
|
||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
return Invocation{}, errors.New("version takes no arguments")
|
return Invocation{}, errors.New("version takes no arguments")
|
||||||
}
|
}
|
||||||
return Invocation{Command: VersionCommand}, nil
|
return Invocation{Command: VersionCommand}, nil
|
||||||
case string(CheckAction), string(PublishAction), string(BuildAction), string(DeliveryAction):
|
case string(CheckAction), string(PublishAction), string(BuildAction), string(DeliveryAction), string(CIDeliveryAction):
|
||||||
action := Command(args[0])
|
action := Command(args[0])
|
||||||
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
|
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
|
||||||
flags.SetOutput(io.Discard)
|
flags.SetOutput(io.Discard)
|
||||||
@@ -63,18 +66,46 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
if action == DeliveryAction {
|
if action == DeliveryAction {
|
||||||
flags.StringVar(&assetsDir, "assets", "", "verified build assets directory")
|
flags.StringVar(&assetsDir, "assets", "", "verified build assets directory")
|
||||||
}
|
}
|
||||||
|
var remote *string
|
||||||
|
expectedCommit := ""
|
||||||
|
if action == CIDeliveryAction {
|
||||||
|
remote = flags.String("remote", "origin", "Git remote")
|
||||||
|
flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit")
|
||||||
|
}
|
||||||
if err := flags.Parse(args[1:]); err != nil {
|
if err := flags.Parse(args[1:]); err != nil {
|
||||||
return Invocation{}, err
|
return Invocation{}, err
|
||||||
}
|
}
|
||||||
if flags.NArg() != 1 {
|
if flags.NArg() != 1 {
|
||||||
return Invocation{}, fmt.Errorf("%s requires one version argument", action)
|
return Invocation{}, fmt.Errorf("%s requires one version argument", action)
|
||||||
}
|
}
|
||||||
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Version: flags.Arg(0)}, nil
|
if action == CIDeliveryAction && expectedCommit == "" {
|
||||||
|
return Invocation{}, errors.New("ci-deliver requires --commit OID")
|
||||||
|
}
|
||||||
|
if action == CIDeliveryAction && !validGitObjectID(expectedCommit) {
|
||||||
|
return Invocation{}, errors.New("ci-deliver --commit must be a full Git object ID")
|
||||||
|
}
|
||||||
|
parsedRemote := ""
|
||||||
|
if remote != nil {
|
||||||
|
parsedRemote = *remote
|
||||||
|
}
|
||||||
|
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: flags.Arg(0)}, nil
|
||||||
default:
|
default:
|
||||||
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validGitObjectID(value string) bool {
|
||||||
|
if len(value) != 40 && len(value) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, char := range value {
|
||||||
|
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// CheckCommand contains one direct child-process invocation from a profile.
|
// CheckCommand contains one direct child-process invocation from a profile.
|
||||||
type CheckCommand struct {
|
type CheckCommand struct {
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
|
|||||||
@@ -47,9 +47,29 @@ func TestParse(t *testing.T) {
|
|||||||
if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" {
|
if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" {
|
||||||
t.Fatalf("Parse(deliver) = %#v", deliver)
|
t.Fatalf("Parse(deliver) = %#v", deliver)
|
||||||
}
|
}
|
||||||
|
ciDelivery, err := Parse([]string{"ci-deliver", "--repo", "candidate", "--config", "profiles/release config.json", "--remote", "upstream", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.7"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ciDelivery.Command != CIDeliveryAction || ciDelivery.Repository != "candidate" || ciDelivery.ConfigPath != "profiles/release config.json" || ciDelivery.Remote != "upstream" || ciDelivery.Commit != "0123456789abcdef0123456789abcdef01234567" || ciDelivery.Version != "v1.2.7" {
|
||||||
|
t.Fatalf("Parse(ci-deliver) = %#v", ciDelivery)
|
||||||
|
}
|
||||||
|
ciDefault, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.8"})
|
||||||
|
if err != nil || ciDefault.Remote != "origin" {
|
||||||
|
t.Fatalf("Parse(ci-deliver defaults) = %#v, error = %v", ciDefault, err)
|
||||||
|
}
|
||||||
|
if _, err := Parse([]string{"ci-deliver", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "--commit") {
|
||||||
|
t.Fatalf("Parse(ci-deliver without commit) error = %v, want required commit", err)
|
||||||
|
}
|
||||||
|
if _, err := Parse([]string{"ci-deliver", "--commit", "abc123", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "full Git object ID") {
|
||||||
|
t.Fatalf("Parse(ci-deliver with abbreviated commit) error = %v, want full object ID error", err)
|
||||||
|
}
|
||||||
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
|
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
|
||||||
t.Fatal("Parse accepted a flag after the version")
|
t.Fatal("Parse accepted a flag after the version")
|
||||||
}
|
}
|
||||||
|
if _, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.3", "--repo", "repository"}); err == nil {
|
||||||
|
t.Fatal("Parse accepted a ci-deliver flag after the version")
|
||||||
|
}
|
||||||
if _, err := Parse(nil); err == nil || !strings.Contains(err.Error(), "command is required") {
|
if _, err := Parse(nil); err == nil || !strings.Contains(err.Error(), "command is required") {
|
||||||
t.Fatalf("Parse(nil) error = %v, want missing-command error", err)
|
t.Fatalf("Parse(nil) error = %v, want missing-command error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
case cli.DeliveryAction:
|
case cli.DeliveryAction:
|
||||||
return runDelivery(ctx, invocation, stdout, stderr)
|
return runDelivery(ctx, invocation, stdout, stderr)
|
||||||
|
case cli.CIDeliveryAction:
|
||||||
|
return runCIDelivery(ctx, invocation, stdout, stderr)
|
||||||
}
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -156,13 +158,54 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr
|
|||||||
return deliverWithSettings(ctx, invocation, settings, stdout, stderr)
|
return deliverWithSettings(ctx, invocation, settings, stdout, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
|
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
|
||||||
|
RepositoryDir: invocation.Repository,
|
||||||
|
Version: invocation.Version,
|
||||||
|
Remote: invocation.Remote,
|
||||||
|
ExpectedCommit: invocation.Commit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "prepare tagged checkout: %v\n", err)
|
||||||
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "load profile: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
destination, status := deliveryDestination(settings, stderr)
|
||||||
|
if status != 0 {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
if settings.Build != nil && len(settings.Build.Targets) != 0 {
|
||||||
|
if err := releasekit.PrepareBuildDependencies(ctx, invocation.Repository); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "prepare build dependencies: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
// deliverWithSettings adapts prepared CLI state to the public delivery facade.
|
// deliverWithSettings adapts prepared CLI state to the public delivery facade.
|
||||||
// CI entry points can reuse it after preparing the checkout and loading its profile.
|
// CI entry points can reuse it after preparing the checkout and loading its profile.
|
||||||
func deliverWithSettings(ctx context.Context, invocation cli.Invocation, settings cli.Settings, stdout, stderr io.Writer) int {
|
func deliverWithSettings(ctx context.Context, invocation cli.Invocation, settings cli.Settings, stdout, stderr io.Writer) int {
|
||||||
|
destination, status := deliveryDestination(settings, stderr)
|
||||||
|
if status != 0 {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliveryDestination(settings cli.Settings, stderr io.Writer) (releasekit.GiteaDestination, int) {
|
||||||
delivery := settings.Delivery
|
delivery := settings.Delivery
|
||||||
if delivery == nil || strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" {
|
if delivery == nil || strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" {
|
||||||
fmt.Fprintln(stderr, "deliver requires base_url, owner, and repository in the profile delivery settings")
|
fmt.Fprintln(stderr, "deliver requires base_url, owner, and repository in the profile delivery settings")
|
||||||
return 2
|
return releasekit.GiteaDestination{}, 2
|
||||||
}
|
}
|
||||||
tokenEnv := delivery.TokenEnv
|
tokenEnv := delivery.TokenEnv
|
||||||
if strings.TrimSpace(tokenEnv) == "" {
|
if strings.TrimSpace(tokenEnv) == "" {
|
||||||
@@ -171,9 +214,12 @@ func deliverWithSettings(ctx context.Context, invocation cli.Invocation, setting
|
|||||||
token, ok := os.LookupEnv(tokenEnv)
|
token, ok := os.LookupEnv(tokenEnv)
|
||||||
if !ok || strings.TrimSpace(token) == "" {
|
if !ok || strings.TrimSpace(token) == "" {
|
||||||
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
|
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
|
||||||
return 2
|
return releasekit.GiteaDestination{}, 2
|
||||||
}
|
}
|
||||||
|
return releasekit.GiteaDestination{BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.GiteaDestination, stdout, stderr io.Writer) int {
|
||||||
hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0
|
hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0
|
||||||
if invocation.AssetsDir != "" && !hasTargets {
|
if invocation.AssetsDir != "" && !hasTargets {
|
||||||
fmt.Fprintln(stderr, "--assets requires build targets in the profile")
|
fmt.Fprintln(stderr, "--assets requires build targets in the profile")
|
||||||
@@ -207,12 +253,10 @@ func deliverWithSettings(ctx context.Context, invocation cli.Invocation, setting
|
|||||||
RepositoryDir: invocation.Repository,
|
RepositoryDir: invocation.Repository,
|
||||||
Product: settings.Product,
|
Product: settings.Product,
|
||||||
Version: invocation.Version,
|
Version: invocation.Version,
|
||||||
Destination: releasekit.GiteaDestination{
|
Destination: destination,
|
||||||
BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token,
|
Build: buildSpec,
|
||||||
},
|
Prebuilt: prebuilt,
|
||||||
Build: buildSpec,
|
Output: stderr,
|
||||||
Prebuilt: prebuilt,
|
|
||||||
Output: stderr,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err)
|
fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err)
|
||||||
|
|||||||
@@ -325,6 +325,145 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire example.com/unavailable/module v1.2.3\n")
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "declare unavailable dependency")
|
||||||
|
|
||||||
|
state := &deliveryServerState{}
|
||||||
|
server := newDeliveryServer(t, state)
|
||||||
|
defer server.Close()
|
||||||
|
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{
|
||||||
|
Product: "Example Product",
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
|
})
|
||||||
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
|
state.commit = expectedCommit
|
||||||
|
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{Product: "Later Branch Profile"})
|
||||||
|
|
||||||
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||||
|
t.Setenv("GOPROXY", "off")
|
||||||
|
t.Setenv("GOSUMDB", "off")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
args := []string{"ci-deliver", "--repo", repository, "--config", "profiles/ci-delivery.json", "--commit", expectedCommit, "v1.2.3"}
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
|
||||||
|
t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if state.releaseCreates != 1 || state.apiRequests == 0 {
|
||||||
|
t.Fatalf("page delivery did not reach the server: creates=%d requests=%d", state.releaseCreates, state.apiRequests)
|
||||||
|
}
|
||||||
|
if got := cliGitOutput(t, repository, "rev-parse", "HEAD"); got != expectedCommit {
|
||||||
|
t.Fatalf("prepared HEAD = %s, want %s", got, expectedCommit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire example.com/unavailable/module v1.2.3\n")
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "declare unavailable dependency")
|
||||||
|
state := &deliveryServerState{}
|
||||||
|
server := newDeliveryServer(t, state)
|
||||||
|
defer server.Close()
|
||||||
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
|
Product: "Example Product",
|
||||||
|
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
|
})
|
||||||
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
|
state.commit = expectedCommit
|
||||||
|
|
||||||
|
shimDir := t.TempDir()
|
||||||
|
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||||
|
goShim := "#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(goShim), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||||
|
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
wrongCommit := strings.Repeat("0", 40)
|
||||||
|
args := []string{"ci-deliver", "--repo", repository, "--commit", wrongCommit, "v1.2.3"}
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 1 {
|
||||||
|
t.Fatalf("run() status=%d stdout=%q stderr=%q, want preparation failure", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "resolves to commit") {
|
||||||
|
t.Fatalf("stderr=%q, want expected-commit mismatch", stderr.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Go was invoked before commit agreement: %v", err)
|
||||||
|
}
|
||||||
|
if state.apiRequests != 0 {
|
||||||
|
t.Fatalf("hosted API requests = %d, want 0", state.apiRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) {
|
||||||
|
repository := newDeliveryBuildRepository(t)
|
||||||
|
state := &deliveryServerState{}
|
||||||
|
server := newDeliveryServer(t, state)
|
||||||
|
defer server.Close()
|
||||||
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
|
Product: "Example Product",
|
||||||
|
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}},
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
|
})
|
||||||
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
|
state.commit = expectedCommit
|
||||||
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
args := []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
|
||||||
|
t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if state.releaseCreates != 1 || state.assetUploads != 2 {
|
||||||
|
t.Fatalf("delivery counts = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
|
||||||
|
}
|
||||||
|
if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
|
||||||
|
t.Fatalf("binary not uploaded: %#v", state.assets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T) {
|
||||||
|
repository := newDeliveryBuildRepository(t)
|
||||||
|
state := &deliveryServerState{}
|
||||||
|
server := newDeliveryServer(t, state)
|
||||||
|
defer server.Close()
|
||||||
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
|
Product: "Example Product",
|
||||||
|
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
|
||||||
|
})
|
||||||
|
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||||
|
state.commit = expectedCommit
|
||||||
|
|
||||||
|
shimDir := t.TempDir()
|
||||||
|
markerPath := filepath.Join(shimDir, "go-invoked")
|
||||||
|
goShim := "#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(goShim), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
|
||||||
|
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
args := []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 2 {
|
||||||
|
t.Fatalf("run() status=%d stdout=%q stderr=%q, want credential error", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "non-empty token") {
|
||||||
|
t.Fatalf("stderr=%q, want missing-credential error", stderr.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Go was invoked before credential validation: %v", err)
|
||||||
|
}
|
||||||
|
if state.apiRequests != 0 {
|
||||||
|
t.Fatalf("hosted API requests = %d, want 0", state.apiRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
|
func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
|
||||||
repository := newDeliveryBuildRepository(t)
|
repository := newDeliveryBuildRepository(t)
|
||||||
state := &deliveryServerState{}
|
state := &deliveryServerState{}
|
||||||
@@ -701,15 +840,23 @@ func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *delive
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) {
|
func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) {
|
||||||
|
writeDeliveryProfileAt(t, repository, "releasekit.json", settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDeliveryProfileAt(t *testing.T, repository, name string, settings cli.Settings) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
contents, err := json.Marshal(settings)
|
contents, err := json.Marshal(settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(repository, "releasekit.json"), contents, 0o600); err != nil {
|
path := filepath.Join(repository, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cliGitRun(t, repository, "add", "releasekit.json")
|
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cliGitRun(t, repository, "add", name)
|
||||||
cliGitRun(t, repository, "commit", "-m", "configure delivery")
|
cliGitRun(t, repository, "commit", "-m", "configure delivery")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,6 +926,28 @@ func cliGitRun(t *testing.T, repository string, args ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeRepositoryFile(t *testing.T, repository, name, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(repository, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushTaggedCLIRepository(t *testing.T, repository string) string {
|
||||||
|
t.Helper()
|
||||||
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
|
commit := cliGitOutput(t, repository, "rev-parse", "v1.2.3^{commit}")
|
||||||
|
remote := filepath.Join(t.TempDir(), "origin.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", remote)
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
return commit
|
||||||
|
}
|
||||||
|
|
||||||
func cliGitOutput(t *testing.T, repository string, args ...string) string {
|
func cliGitOutput(t *testing.T, repository string, args ...string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
commandArgs := append([]string{"git"}, args...)
|
commandArgs := append([]string{"git"}, args...)
|
||||||
|
|||||||
29
docs/cli.md
29
docs/cli.md
@@ -1,6 +1,6 @@
|
|||||||
# Command line
|
# Command line
|
||||||
|
|
||||||
ReleaseKit currently provides five commands. The exit statuses below belong
|
ReleaseKit currently provides six commands. The exit statuses below belong
|
||||||
to the native executable. `go run` examples are convenient for source execution,
|
to the native executable. `go run` examples are convenient for source execution,
|
||||||
but the Go driver maps a nonzero child exit status to 1. Use the built executable
|
but the Go driver maps a nonzero child exit status to 1. Use the built executable
|
||||||
or `./scripts/release` for publication when distinct statuses are needed.
|
or `./scripts/release` for publication when distinct statuses are needed.
|
||||||
@@ -125,3 +125,30 @@ artifacts and retry with `--assets DIR`. Matching content is kept and only
|
|||||||
known missing assets are added. See the
|
known missing assets are added. See the
|
||||||
[delivery consumer guide](consumers/delivery.md) and
|
[delivery consumer guide](consumers/delivery.md) and
|
||||||
[Gitea integration guide](integrations/gitea.md).
|
[Gitea integration guide](integrations/gitea.md).
|
||||||
|
|
||||||
|
Prepare a tagged CI checkout and deliver it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION
|
||||||
|
```
|
||||||
|
|
||||||
|
`ci-deliver` fetches `VERSION` from the selected Git remote (default `origin`),
|
||||||
|
requires the tag's peeled commit to match the required full `--commit` object
|
||||||
|
ID, and checks out that commit detached. It then loads the profile from the
|
||||||
|
prepared checkout, validates delivery settings and the configured token, and
|
||||||
|
downloads application dependencies only when the profile selects build targets.
|
||||||
|
Binary profiles are built and delivered through the same facade path as
|
||||||
|
`deliver`; source-only profiles skip dependency downloads and binary builds.
|
||||||
|
`--config` follows the existing profile path rules: relative paths are resolved
|
||||||
|
from `--repo`, and absolute paths are used as-is. This command explicitly
|
||||||
|
prepares and changes the selected checkout. Ordinary `deliver` continues to
|
||||||
|
operate on already prepared source without fetching, checking out, or
|
||||||
|
downloading dependencies.
|
||||||
|
|
||||||
|
In Woodpecker, pass `CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the
|
||||||
|
repository, expected commit, and version arguments. The CLI does not read
|
||||||
|
provider-specific environment variable names. Checkout and dependency
|
||||||
|
preparation failures return status 1; usage, profile, credential, and invalid
|
||||||
|
version errors return status 2. Final delivery uses the same statuses as
|
||||||
|
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery,
|
||||||
|
and 3 for uncertain remote state.
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ retained for retries or when build and delivery credentials need separate
|
|||||||
invocations. Ordinary `deliver` does not download application dependencies or
|
invocations. Ordinary `deliver` does not download application dependencies or
|
||||||
change the checkout.
|
change the checkout.
|
||||||
|
|
||||||
|
`ci-deliver` uses the same profile after preparing the selected tag in the
|
||||||
|
checkout. It checks out the tag only after its peeled commit matches the
|
||||||
|
provider-supplied commit ID, then loads the profile and validates delivery
|
||||||
|
settings and credentials. It runs `go mod download` only when the profile has
|
||||||
|
selected build targets; source-only delivery does not need application
|
||||||
|
dependencies. This preparation may access the network and may update
|
||||||
|
`go.sum`. The command takes CI values as arguments, so profile settings remain
|
||||||
|
independent of Woodpecker environment variable names. See the
|
||||||
|
[CI command contract](cli.md#prepare-a-tagged-ci-checkout-and-deliver-it) for
|
||||||
|
the invocation and exit statuses.
|
||||||
|
|
||||||
ReleaseKit's source-only profile is
|
ReleaseKit's source-only profile is
|
||||||
[releasekit.json](../releasekit.json).
|
[releasekit.json](../releasekit.json).
|
||||||
See the [source-only profile](../examples/releasekit.json) and
|
See the [source-only profile](../examples/releasekit.json) and
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReleaseKit audit implementation plan
|
# ReleaseKit audit implementation plan
|
||||||
|
|
||||||
Status: Stages 1–8 complete; Stage 9 next.
|
Status: Stages 1–9 complete; Stage 10 next.
|
||||||
Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
|
Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
|
||||||
product goals, and selected direction. This document owns implementation order
|
product goals, and selected direction. This document owns implementation order
|
||||||
and completion criteria for that work.
|
and completion criteria for that work.
|
||||||
@@ -460,6 +460,16 @@ a wrong CI commit fails before application builds or hosted writes, and a
|
|||||||
source-only module with unavailable dependencies still reaches page delivery.
|
source-only module with unavailable dependencies still reaches page delivery.
|
||||||
Reuse facade tests for the individual preparation and delivery operations.
|
Reuse facade tests for the individual preparation and delivery operations.
|
||||||
|
|
||||||
|
**Completed:** `ci-deliver` now prepares the tagged checkout against the
|
||||||
|
provider-supplied commit, loads the profile from that checkout, validates its
|
||||||
|
delivery settings and credential, downloads dependencies only for selected
|
||||||
|
binary targets, and finishes through the existing delivery facade. Local
|
||||||
|
Git/HTTP fixtures cover source-only and binary delivery, relative custom profile
|
||||||
|
loading, commit mismatch before Go or hosted requests, and unavailable
|
||||||
|
source-only dependencies. CLI and configuration guidance describe preparation,
|
||||||
|
provider arguments, effects, and exit statuses. The full offline suite, API
|
||||||
|
boundary check, and documentation whitespace checks pass.
|
||||||
|
|
||||||
## Stage 10 — Consolidate and exercise maintained CI templates
|
## Stage 10 — Consolidate and exercise maintained CI templates
|
||||||
|
|
||||||
**Covers:** installation, workflow duplication, and artifact-testing in F6.
|
**Covers:** installation, workflow duplication, and artifact-testing in F6.
|
||||||
|
|||||||
Reference in New Issue
Block a user