Read delivery tokens from files and check selected paths before release
This commit is contained in:
11
check.go
11
check.go
@@ -59,6 +59,8 @@ type ReleaseRequest struct {
|
|||||||
// module and shared-file inputs. Independent nested modules form automatic
|
// module and shared-file inputs. Independent nested modules form automatic
|
||||||
// input boundaries; vendored files remain included. Referenced destinations
|
// input boundaries; vendored files remain included. Referenced destinations
|
||||||
// remain available for targeted inspection without expanding the input set.
|
// remain available for targeted inspection without expanding the input set.
|
||||||
|
// UntrackedFiles checks explicitly selected repository paths independently of
|
||||||
|
// those file inputs.
|
||||||
type ValidationSpec struct {
|
type ValidationSpec struct {
|
||||||
// SharedFiles adds repository-relative committed files to optional file
|
// SharedFiles adds repository-relative committed files to optional file
|
||||||
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and
|
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and
|
||||||
@@ -66,7 +68,11 @@ type ValidationSpec struct {
|
|||||||
// Explicit files replace the defaults and must exist even when file checks
|
// Explicit files replace the defaults and must exist even when file checks
|
||||||
// are off.
|
// are off.
|
||||||
SharedFiles []string
|
SharedFiles []string
|
||||||
Go GoValidationSpec
|
// UntrackedFiles lists literal repository-relative file paths that must be
|
||||||
|
// absent from the captured commit, independently of module/shared inputs.
|
||||||
|
// An empty list disables the check. File contents and history are not read.
|
||||||
|
UntrackedFiles []string
|
||||||
|
Go GoValidationSpec
|
||||||
// GoFormat checks the formatting of committed regular Go source files. It
|
// GoFormat checks the formatting of committed regular Go source files. It
|
||||||
// does not modify repository contents and is disabled when false.
|
// does not modify repository contents and is disabled when false.
|
||||||
GoFormat bool
|
GoFormat bool
|
||||||
@@ -137,6 +143,8 @@ type GoValidationSpec struct {
|
|||||||
// of test-time vet, and Validation.Go.CompileTargets compiles all packages for
|
// of test-time vet, and Validation.Go.CompileTargets compiles all packages for
|
||||||
// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
|
// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
|
||||||
// compilation produces no artifacts and does not execute target binaries.
|
// compilation produces no artifacts and does not execute target binaries.
|
||||||
|
// Validation.UntrackedFiles checks that selected repository-relative files are
|
||||||
|
// absent from the captured commit after Go checks and before other file checks.
|
||||||
// Validation.GoFormat checks regular committed Go source after those selected
|
// Validation.GoFormat checks regular committed Go source after those selected
|
||||||
// Go checks and before AdditionalChecks. Its zero value disables formatting.
|
// Go checks and before AdditionalChecks. Its zero value disables formatting.
|
||||||
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
|
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
|
||||||
@@ -189,6 +197,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
|
|||||||
FullVet: request.Validation.Go.FullVet,
|
FullVet: request.Validation.Go.FullVet,
|
||||||
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
|
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
|
||||||
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
||||||
|
UntrackedFiles: copyStringSlice(request.Validation.UntrackedFiles),
|
||||||
GoFormat: request.Validation.GoFormat,
|
GoFormat: request.Validation.GoFormat,
|
||||||
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
||||||
ModuleDistribution: validation.ModuleDistributionSettings{
|
ModuleDistribution: validation.ModuleDistributionSettings{
|
||||||
|
|||||||
@@ -153,8 +153,9 @@ type Settings struct {
|
|||||||
type ValidationSettings struct {
|
type ValidationSettings struct {
|
||||||
// SharedFiles selects committed files relative to the repository root.
|
// SharedFiles selects committed files relative to the repository root.
|
||||||
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
|
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
|
||||||
SharedFiles []string `json:"shared_files"`
|
SharedFiles []string `json:"shared_files"`
|
||||||
Go GoValidationSettings `json:"go,omitempty"`
|
UntrackedFiles []string `json:"untracked_files,omitempty"`
|
||||||
|
Go GoValidationSettings `json:"go,omitempty"`
|
||||||
// GoFormat checks the formatting of committed regular Go source files.
|
// GoFormat checks the formatting of committed regular Go source files.
|
||||||
// Omitted or false disables the check.
|
// Omitted or false disables the check.
|
||||||
GoFormat bool `json:"go_format,omitempty"`
|
GoFormat bool `json:"go_format,omitempty"`
|
||||||
@@ -201,6 +202,7 @@ type DeliverySettings struct {
|
|||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
TokenEnv string `json:"token_env,omitempty"`
|
TokenEnv string `json:"token_env,omitempty"`
|
||||||
|
TokenFile string `json:"token_file,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSettings selects the Go application package, artifact name, and targets.
|
// BuildSettings selects the Go application package, artifact name, and targets.
|
||||||
|
|||||||
@@ -181,8 +181,9 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
|
|||||||
return releasekit.ValidationSpec{}
|
return releasekit.ValidationSpec{}
|
||||||
}
|
}
|
||||||
result := releasekit.ValidationSpec{
|
result := releasekit.ValidationSpec{
|
||||||
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||||
GoFormat: settings.GoFormat,
|
UntrackedFiles: copyStringsPreservingEmpty(settings.UntrackedFiles),
|
||||||
|
GoFormat: settings.GoFormat,
|
||||||
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||||
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
||||||
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
||||||
@@ -290,7 +291,7 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
|||||||
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
destination, status := deliveryDestination(settings, stderr)
|
destination, status := deliveryDestination(invocation.Repository, settings, stderr)
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
@@ -309,14 +310,14 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
|
|||||||
// 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)
|
destination, status := deliveryDestination(invocation.Repository, settings, stderr)
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr)
|
return deliverWithDestination(ctx, invocation, settings, destination, stdout, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deliveryDestination(settings cli.Settings, stderr io.Writer) (releasekit.GiteaDestination, int) {
|
func deliveryDestination(repositoryDir string, 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")
|
||||||
@@ -327,7 +328,23 @@ func deliveryDestination(settings cli.Settings, stderr io.Writer) (releasekit.Gi
|
|||||||
tokenEnv = "RELEASEKIT_GITEA_TOKEN"
|
tokenEnv = "RELEASEKIT_GITEA_TOKEN"
|
||||||
}
|
}
|
||||||
token, ok := os.LookupEnv(tokenEnv)
|
token, ok := os.LookupEnv(tokenEnv)
|
||||||
if !ok || strings.TrimSpace(token) == "" {
|
if (!ok || strings.TrimSpace(token) == "") && delivery.TokenFile != "" {
|
||||||
|
name := delivery.TokenFile
|
||||||
|
if !filepath.IsAbs(name) {
|
||||||
|
name = filepath.Join(repositoryDir, name)
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "read delivery token file: %v\n", err)
|
||||||
|
return releasekit.GiteaDestination{}, 2
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(string(contents))
|
||||||
|
if token == "" {
|
||||||
|
fmt.Fprintln(stderr, "delivery token file is empty or blank")
|
||||||
|
return releasekit.GiteaDestination{}, 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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 releasekit.GiteaDestination{}, 2
|
return releasekit.GiteaDestination{}, 2
|
||||||
}
|
}
|
||||||
|
|||||||
111
cmd/releasekit/token_file_test.go
Normal file
111
cmd/releasekit/token_file_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeliveryTokenFileSelection(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, env, contents, file, want string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{name: "relative file", contents: "fixture-token\n", file: "token", want: "fixture-token"},
|
||||||
|
{name: "absolute file", contents: "fixture-token\n", file: "absolute", want: "fixture-token"},
|
||||||
|
{name: "environment overrides missing file", env: "env-token", file: "missing", want: "env-token"},
|
||||||
|
{name: "blank environment falls back", env: " \t", contents: "fixture-token\n", file: "token", want: "fixture-token"},
|
||||||
|
{name: "missing file", file: "missing", status: 2},
|
||||||
|
{name: "blank file", contents: " \n\t", file: "token", status: 2},
|
||||||
|
{name: "empty file", file: "token", status: 2},
|
||||||
|
{name: "unreadable as file", file: ".", status: 2},
|
||||||
|
{name: "no credential", status: 2},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repository := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(repository, "token"), []byte(test.contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name := test.file
|
||||||
|
if name == "absolute" {
|
||||||
|
name = filepath.Join(repository, "token")
|
||||||
|
}
|
||||||
|
t.Setenv("RELEASEKIT_TEST_TOKEN", test.env)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
destination, status := deliveryDestination(repository, cli.Settings{Delivery: &cli.DeliverySettings{
|
||||||
|
BaseURL: "https://gitea.example", Owner: "owner", Repository: "example",
|
||||||
|
TokenEnv: "RELEASEKIT_TEST_TOKEN", TokenFile: name,
|
||||||
|
}}, &stderr)
|
||||||
|
if status != test.status || destination.Token != test.want {
|
||||||
|
t.Fatalf("status=%d token matches=%t, want status=%d; diagnostics=%s", status, destination.Token == test.want, test.status, &stderr)
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"fixture-token", "env-token"} {
|
||||||
|
if strings.Contains(stderr.String(), secret) {
|
||||||
|
t.Fatal("diagnostics exposed a token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckReadsUntrackedFilesSelectionFromProfile(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
|
Product: "Example Product",
|
||||||
|
Validation: &cli.ValidationSettings{UntrackedFiles: []string{"token-file"}},
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: "https://gitea.example", Owner: "owner", Repository: "example", TokenFile: "missing-token-file"},
|
||||||
|
})
|
||||||
|
writeRepositoryFile(t, repository, "token-file", "fixture-secret\n")
|
||||||
|
cliGitRun(t, repository, "add", "token-file")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "capture a selected token path")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"check", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
|
||||||
|
if status != 1 || !strings.Contains(stderr.String(), "untracked-files") || !strings.Contains(stderr.String(), "token-file") {
|
||||||
|
t.Fatalf("check status=%d output=%s diagnostics=%s", status, &stdout, &stderr)
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String()+stderr.String(), "fixture-secret") {
|
||||||
|
t.Fatal("check exposed the token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverAndCIDeliverReadTokenFileFromRepositoryRoot(t *testing.T) {
|
||||||
|
for _, command := range []string{"deliver", "ci-deliver"} {
|
||||||
|
t.Run(command, func(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
state := &deliveryServerState{}
|
||||||
|
server := newDeliveryServer(t, state)
|
||||||
|
defer server.Close()
|
||||||
|
writeDeliveryProfile(t, repository, cli.Settings{
|
||||||
|
Product: "Example Product",
|
||||||
|
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example", TokenFile: ".releasekit/token"},
|
||||||
|
})
|
||||||
|
writeRepositoryFile(t, repository, ".gitignore", ".releasekit/\n")
|
||||||
|
cliGitRun(t, repository, "add", ".gitignore")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "ignore local delivery credentials")
|
||||||
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
|
state.update(func(data *deliveryServerData) { data.commit = commit })
|
||||||
|
writeRepositoryFile(t, repository, ".releasekit/token", "fixture-token\n")
|
||||||
|
t.Setenv("RELEASEKIT_GITEA_TOKEN", "")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
args := []string{command, "--repo", filepath.Join(repository, ".releasekit")}
|
||||||
|
if command == "ci-deliver" {
|
||||||
|
args = append(args, "--commit", commit)
|
||||||
|
}
|
||||||
|
args = append(args, "v1.2.3")
|
||||||
|
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
|
||||||
|
t.Fatalf("delivery status=%d output=%s diagnostics=%s", status, &stdout, &stderr)
|
||||||
|
}
|
||||||
|
if state.snapshot().releaseCreates != 1 {
|
||||||
|
t.Fatal("token-file delivery did not create the release page")
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String()+stderr.String(), "fixture-token") {
|
||||||
|
t.Fatal("delivery exposed the token")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,8 +174,9 @@ Deliver a tagged source release to Gitea:
|
|||||||
go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION
|
go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION
|
||||||
```
|
```
|
||||||
|
|
||||||
`deliver` requires a `delivery` profile object and its configured token
|
`deliver` requires a `delivery` profile object and a token loaded from its
|
||||||
environment variable, which defaults to `RELEASEKIT_GITEA_TOKEN`. It verifies
|
configured environment variable or token file. Credential selection and errors
|
||||||
|
are defined in the [configuration reference](config.md). It verifies
|
||||||
the local tag, clean tagged checkout, hosted tag, and committed release note,
|
the local tag, clean tagged checkout, hosted tag, and committed release note,
|
||||||
then creates or finds the release page and reports delivery state separately
|
then creates or finds the release page and reports delivery state separately
|
||||||
from source publication. Historical lightweight tags and detached checkouts
|
from source publication. Historical lightweight tags and detached checkouts
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ The current profile fields are:
|
|||||||
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
|
||||||
| `validation` | No | Optional shared checks selected by ReleaseKit. |
|
| `validation` | No | Optional shared checks selected by ReleaseKit. |
|
||||||
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
|
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
|
||||||
| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. |
|
| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, `token_env`, and `token_file` settings for tagged-source delivery. |
|
||||||
|
|
||||||
`module_dir` names the selected module's physical location in the repository;
|
`module_dir` names the selected module's physical location in the repository;
|
||||||
ReleaseKit reads its declared import path from that module's own `go.mod`. The
|
ReleaseKit reads its declared import path from that module's own `go.mod`. The
|
||||||
@@ -141,12 +141,32 @@ symlink size is the committed link text. Gitlinks have no measured size. Only
|
|||||||
the captured candidate is checked, one offending file is reported at a time,
|
the captured candidate is checked, one offending file is reported at a time,
|
||||||
and historical or aggregate repository size is not measured.
|
and historical or aggregate repository size is not measured.
|
||||||
|
|
||||||
|
`validation.untracked_files` is an optional list of literal repository-relative
|
||||||
|
file paths that must be absent from the captured commit. It runs after the Go
|
||||||
|
checks and before other file checks, using the `untracked-files` identity and
|
||||||
|
reporting selected committed paths. Omission, `null`, or `[]` disables it.
|
||||||
|
Paths use slash separators and ordinary relative normalization and must stay
|
||||||
|
within the repository. They are checked against the whole captured repository,
|
||||||
|
independently of the selected module and `shared_files`. The check reads path
|
||||||
|
metadata only; it does not read token contents, follow symlink targets, require
|
||||||
|
local files to exist, or inspect Git history. Existing clean-index/worktree
|
||||||
|
requirements still apply to candidate operations. An ignore rule alone does
|
||||||
|
not remove a previously committed file from the candidate.
|
||||||
|
|
||||||
The `delivery` object contains `base_url`, `owner`, `repository`, and optional
|
The `delivery` object contains `base_url`, `owner`, `repository`, and optional
|
||||||
`token_env`. Its values are required when running `deliver`, but credentials are
|
`token_env` and `token_file`. `token_env` names the environment variable
|
||||||
not required for `check`, `publish`, or `build`. `token_env` names the
|
containing the Gitea token and defaults to `RELEASEKIT_GITEA_TOKEN`. A nonblank
|
||||||
environment variable containing the Gitea token; it defaults to
|
environment value takes precedence. When it is unset or blank, `token_file`
|
||||||
`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
|
selects a plain token-containing file; relative paths resolve from the Git
|
||||||
passes its value explicitly to the library operation.
|
repository root, and absolute paths are used as supplied. The CLI trims leading
|
||||||
|
and trailing whitespace from file contents. An unreadable or blank file, or
|
||||||
|
the absence of both credential sources, returns status 2. Omission of
|
||||||
|
`token_file` retains environment-only loading.
|
||||||
|
|
||||||
|
Credential loading runs only for `deliver` and `ci-deliver`. `check`, `publish`,
|
||||||
|
and `build` do not read the token file or require a token. The CLI passes the
|
||||||
|
resolved value explicitly to the library operation. Selecting a token file and
|
||||||
|
selecting the optional untracked-file validator are independent settings.
|
||||||
|
|
||||||
`expected_push_url`, when nonempty, is compared exactly with Git's resolved
|
`expected_push_url`, when nonempty, is compared exactly with Git's resolved
|
||||||
effective push URL during `publish`, before candidate validation. It applies
|
effective push URL during `publish`, before candidate validation. It applies
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ The `Candidate` supplies both `RepositoryDir` and `ModuleDir`, plus the declared
|
|||||||
module-local product checks. ReleaseKit calls `AdditionalChecks` once after
|
module-local product checks. ReleaseKit calls `AdditionalChecks` once after
|
||||||
shared validation when supplied.
|
shared validation when supplied.
|
||||||
|
|
||||||
|
For local token files, set `Validation.UntrackedFiles` to the repository-relative
|
||||||
|
paths that should stay out of the committed candidate, such as
|
||||||
|
`[]string{".releasekit/gitea-token"}`. Failures use `untracked-files` and carry the
|
||||||
|
selected committed paths in `ValidationError.Findings`. This selection is
|
||||||
|
independent of module and shared-file inputs; see [GoDoc](../../check.go) for
|
||||||
|
the API contract and the [configuration reference](../config.md) for CLI use.
|
||||||
|
|
||||||
`ValidationSpec.SharedFiles` adds committed, repository-relative files to
|
`ValidationSpec.SharedFiles` adds committed, repository-relative files to
|
||||||
ReleaseKit's optional file checks without enabling any checks. A nil slice
|
ReleaseKit's optional file checks without enabling any checks. A nil slice
|
||||||
selects the optional defaults `COPYRIGHT`, `LICENSE`, and
|
selects the optional defaults `COPYRIGHT`, `LICENSE`, and
|
||||||
|
|||||||
@@ -77,11 +77,36 @@ git checkout --detach v1.2.3
|
|||||||
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver v1.2.3
|
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver v1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI reads the named token variable only for `deliver`; its default name is
|
The CLI resolves the token for delivery using the configured environment
|
||||||
`RELEASEKIT_GITEA_TOKEN`. It verifies the local checkout and hosted tag before
|
variable or token file. It verifies the local checkout and hosted tag before
|
||||||
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 local delivery, save a Gitea personal access token in a git-ignored file such
|
||||||
|
as `.releasekit/gitea-token` and configure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product": "Example",
|
||||||
|
"validation": {
|
||||||
|
"untracked_files": [".releasekit/gitea-token"]
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"base_url": "https://gitea.example",
|
||||||
|
"owner": "example",
|
||||||
|
"repository": "example",
|
||||||
|
"token_file": ".releasekit/gitea-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `.releasekit/` to the consumer's ignore rules and use owner-only file
|
||||||
|
permissions for the saved token. Then run `releasekit deliver v1.2.3` from the
|
||||||
|
tagged checkout. CI can continue supplying the environment token. See the
|
||||||
|
[configuration reference](../config.md) for precedence, path resolution, and the
|
||||||
|
optional candidate check. The validator checks the selected committed paths;
|
||||||
|
it does not scan for secrets in other files or past commits.
|
||||||
For nested modules, the note comes from that module's committed
|
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.
|
`docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ workspace-file check using the same candidate inventory. Resolved module bodies
|
|||||||
are parsed once per blob object ID; only owned filesystem-replacement targets
|
are parsed once per blob object ID; only owned filesystem-replacement targets
|
||||||
and line numbers remain cached. Each declaration path receives findings from
|
and line numbers remain cached. Each declaration path receives findings from
|
||||||
that summary in its own path context.
|
that summary in its own path context.
|
||||||
|
The optional untracked-file check uses the captured tree's full path lookup
|
||||||
|
before the other file checks. It normalizes the explicitly selected
|
||||||
|
repository-relative names and reports matching entries through `untracked-files`
|
||||||
|
without loading their blobs. Candidate-tree cleanup covers this failure path.
|
||||||
The optional file-size check applies normalized literal file/directory
|
The optional file-size check applies normalized literal file/directory
|
||||||
exclusions with path-boundary matching, then requests local Git sizes for the
|
exclusions with path-boundary matching, then requests local Git sizes for the
|
||||||
remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
|
remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ succeeded. CI and consumer installation are not completion checks.
|
|||||||
|
|
||||||
## Prepare a candidate
|
## Prepare a candidate
|
||||||
|
|
||||||
|
For local hosted-page delivery, save a Gitea personal access token in
|
||||||
|
`.releasekit/gitea-token` with owner-only file permissions. The file contains
|
||||||
|
only the token. ReleaseKit's profile selects this file as a fallback to
|
||||||
|
`RELEASEKIT_GITEA_TOKEN`; `.releasekit/` is already ignored. Its optional
|
||||||
|
`validation.untracked_files` selection checks that this path is absent from the
|
||||||
|
committed candidate. Source publication requires no delivery token. The
|
||||||
|
[configuration reference](config.md) owns credential selection and validator
|
||||||
|
semantics.
|
||||||
|
|
||||||
Use a clean clone on `main`. The chosen build toolchain is Go 1.26.10. Make that
|
Use a clean clone on `main`. The chosen build toolchain is Go 1.26.10. Make that
|
||||||
local installation the active `go` command for preparation, building the release
|
local installation the active `go` command for preparation, building the release
|
||||||
executable, and candidate validation:
|
executable, and candidate validation:
|
||||||
@@ -150,7 +159,15 @@ git checkout --detach "vX.Y.Z"
|
|||||||
GOTOOLCHAIN=local go mod download
|
GOTOOLCHAIN=local go mod download
|
||||||
mkdir -p .releasekit/bin
|
mkdir -p .releasekit/bin
|
||||||
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit
|
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"
|
.releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||||
|
```
|
||||||
|
|
||||||
|
For an older tag whose CLI predates `token_file`, supply the token through its
|
||||||
|
environment variable when retrying that tagged tool:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RELEASEKIT_GITEA_TOKEN="$(cat .releasekit/gitea-token)" \
|
||||||
|
.releasekit/bin/releasekit deliver "vX.Y.Z"
|
||||||
```
|
```
|
||||||
|
|
||||||
The root ignore file excludes `.releasekit/`. Delivery uses the committed note
|
The root ignore file excludes `.releasekit/`. Delivery uses the committed note
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const (
|
|||||||
MarkdownLinks Check = "markdown-links"
|
MarkdownLinks Check = "markdown-links"
|
||||||
// SharedFiles identifies a requested shared file that is unavailable.
|
// SharedFiles identifies a requested shared file that is unavailable.
|
||||||
SharedFiles Check = "shared-files"
|
SharedFiles Check = "shared-files"
|
||||||
|
// UntrackedFiles identifies selected paths present in the committed source.
|
||||||
|
UntrackedFiles Check = "untracked-files"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Finding describes a validation observation at an optional source location.
|
// Finding describes a validation observation at an optional source location.
|
||||||
|
|||||||
24
internal/validation/untracked_files.go
Normal file
24
internal/validation/untracked_files.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkUntrackedFiles(tree *CandidateTree, names []string) error {
|
||||||
|
var findings []Finding
|
||||||
|
for _, name := range names {
|
||||||
|
clean := path.Clean(name)
|
||||||
|
if name == "" || path.IsAbs(name) || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||||
|
findings = append(findings, Finding{Path: name, Message: "expected a repository-relative file path"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := tree.Entry(clean); exists {
|
||||||
|
findings = append(findings, Finding{Path: clean, Message: "file must be absent from the committed candidate"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(findings) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Failure{Check: UntrackedFiles, Findings: findings}
|
||||||
|
}
|
||||||
49
internal/validation/untracked_files_test.go
Normal file
49
internal/validation/untracked_files_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUntrackedFilesUsesCapturedRepositoryPaths(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/token", []byte("fixture-secret\n"))
|
||||||
|
writeTreeFile(t, repository, ".gitignore", []byte(".releasekit/\n"))
|
||||||
|
gitTree(t, repository, "add", ".")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture a committed credential path")
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
writeTreeFile(t, repository, ".releasekit/token", []byte("local-secret\n"))
|
||||||
|
if err := os.Remove(filepath.Join(repository, "nested/token")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installGoShim(t, "", false)
|
||||||
|
request := Request{RepositoryDir: repository, Commit: commit}
|
||||||
|
if err := Run(context.Background(), request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Settings.UntrackedFiles = []string{".releasekit/token", "absent-token"}
|
||||||
|
if err := Run(context.Background(), request); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Settings.UntrackedFiles = []string{"nested/./token"}
|
||||||
|
err := Run(context.Background(), request)
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != UntrackedFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "nested/token" {
|
||||||
|
t.Fatalf("untracked-files error = %v, want committed nested path despite worktree deletion", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "fixture-secret") {
|
||||||
|
t.Fatal("validation exposed committed token bytes")
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"", "../token", "/token"} {
|
||||||
|
request.Settings.UntrackedFiles = []string{invalid}
|
||||||
|
if err := Run(context.Background(), request); err == nil {
|
||||||
|
t.Fatalf("invalid path %q was accepted", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type Settings struct {
|
|||||||
FullVet bool
|
FullVet bool
|
||||||
CompileTargets []Target
|
CompileTargets []Target
|
||||||
SharedFiles []string
|
SharedFiles []string
|
||||||
|
UntrackedFiles []string
|
||||||
GoFormat bool
|
GoFormat bool
|
||||||
Markdown *MarkdownSettings
|
Markdown *MarkdownSettings
|
||||||
ModuleDistribution ModuleDistributionSettings
|
ModuleDistribution ModuleDistributionSettings
|
||||||
@@ -134,7 +135,7 @@ func Run(ctx context.Context, request Request) (result error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
needsSharedSelection := len(request.Settings.SharedFiles) != 0
|
||||||
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection {
|
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection || len(request.Settings.UntrackedFiles) != 0 {
|
||||||
tree, err := request.ReadCandidateTree(ctx)
|
tree, err := request.ReadCandidateTree(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var failure *Failure
|
var failure *Failure
|
||||||
@@ -154,6 +155,9 @@ func Run(ctx context.Context, request Request) (result error) {
|
|||||||
if request.Settings.GoFormat {
|
if request.Settings.GoFormat {
|
||||||
check = GoFormat
|
check = GoFormat
|
||||||
}
|
}
|
||||||
|
if len(request.Settings.UntrackedFiles) != 0 {
|
||||||
|
check = UntrackedFiles
|
||||||
|
}
|
||||||
return &Failure{Check: check, Cause: err}
|
return &Failure{Check: check, Cause: err}
|
||||||
}
|
}
|
||||||
lastContentCheck := Check("")
|
lastContentCheck := Check("")
|
||||||
@@ -166,6 +170,9 @@ func Run(ctx context.Context, request Request) (result error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
if err := checkUntrackedFiles(tree, request.Settings.UntrackedFiles); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if request.Settings.GoFormat {
|
if request.Settings.GoFormat {
|
||||||
readsBefore := tree.blobReads
|
readsBefore := tree.blobReads
|
||||||
if err := checkGoFormat(ctx, tree); err != nil {
|
if err := checkGoFormat(ctx, tree); err != nil {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
{
|
{
|
||||||
"product": "ReleaseKit",
|
"product": "ReleaseKit",
|
||||||
"remote": "origin",
|
"remote": "origin",
|
||||||
|
"validation": {
|
||||||
|
"untracked_files": [".releasekit/gitea-token"]
|
||||||
|
},
|
||||||
"delivery": {
|
"delivery": {
|
||||||
"base_url": "https://forge.maximumdirect.net",
|
"base_url": "https://forge.maximumdirect.net",
|
||||||
"owner": "base-layer",
|
"owner": "base-layer",
|
||||||
"repository": "releasekit",
|
"repository": "releasekit",
|
||||||
"token_env": "RELEASEKIT_GITEA_TOKEN"
|
"token_env": "RELEASEKIT_GITEA_TOKEN",
|
||||||
|
"token_file": ".releasekit/gitea-token"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
token_validation_test.go
Normal file
38
token_validation_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package releasekit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommittedTokenFilePreventsCheckAndPublication(t *testing.T) {
|
||||||
|
for _, operation := range []string{"check", "publish"} {
|
||||||
|
t.Run(operation, func(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
writeFile(t, repo.work, "local-token", "fixture-secret\n")
|
||||||
|
gitRun(t, repo.work, "add", "local-token")
|
||||||
|
gitRun(t, repo.work, "commit", "-m", "capture token path")
|
||||||
|
request := releasekit.ReleaseRequest{RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
|
||||||
|
Validation: releasekit.ValidationSpec{UntrackedFiles: []string{"local-token"}},
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if operation == "check" {
|
||||||
|
_, err = releasekit.Check(context.Background(), request)
|
||||||
|
} else {
|
||||||
|
gitRun(t, repo.work, "config", "--unset", "remote.origin.mirror")
|
||||||
|
gitRun(t, repo.work, "push", "origin", "HEAD:refs/heads/main")
|
||||||
|
_, err = releasekit.Publish(context.Background(), request)
|
||||||
|
}
|
||||||
|
var failure *releasekit.ValidationError
|
||||||
|
if !errors.As(err, &failure) || failure.Check != "untracked-files" || len(failure.Findings) != 1 || failure.Findings[0].Path != "local-token" {
|
||||||
|
t.Fatalf("operation error=%v, want public untracked-files diagnostic", err)
|
||||||
|
}
|
||||||
|
if gitText(t, repo.work, "tag", "--list", "v1.2.3") != "" {
|
||||||
|
t.Fatal("token-file validation created a release tag")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ import (
|
|||||||
|
|
||||||
// ValidationCheck identifies a shared validation check that failed.
|
// ValidationCheck identifies a shared validation check that failed.
|
||||||
// Current checks include "go-test", "go-vet", "package-compile", "go-format",
|
// Current checks include "go-test", "go-vet", "package-compile", "go-format",
|
||||||
// "workspace-files", "local-replacements", "file-size", and "markdown-links".
|
// "workspace-files", "local-replacements", "untracked-files", "file-size",
|
||||||
|
// and "markdown-links".
|
||||||
type ValidationCheck string
|
type ValidationCheck string
|
||||||
|
|
||||||
// ValidationFinding describes a failed check observation at an optional
|
// ValidationFinding describes a failed check observation at an optional
|
||||||
|
|||||||
Reference in New Issue
Block a user