From 792bfc4eca510ee72ac0ce646bd3c3bd5a30eac1 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 1 Nov 2026 15:39:36 +0000 Subject: [PATCH] Read delivery tokens from files and check selected paths before release --- check.go | 11 +- cmd/releasekit/internal/cli/config.go | 6 +- cmd/releasekit/main.go | 29 +++-- cmd/releasekit/token_file_test.go | 111 ++++++++++++++++++++ docs/cli.md | 5 +- docs/config.md | 32 ++++-- docs/consumers/check.md | 7 ++ docs/consumers/delivery.md | 29 ++++- docs/internal/validation.md | 4 + docs/release.md | 19 +++- internal/validation/diagnostic.go | 2 + internal/validation/untracked_files.go | 24 +++++ internal/validation/untracked_files_test.go | 49 +++++++++ internal/validation/validation.go | 9 +- releasekit.json | 6 +- token_validation_test.go | 38 +++++++ validation_error.go | 3 +- 17 files changed, 361 insertions(+), 23 deletions(-) create mode 100644 cmd/releasekit/token_file_test.go create mode 100644 internal/validation/untracked_files.go create mode 100644 internal/validation/untracked_files_test.go create mode 100644 token_validation_test.go diff --git a/check.go b/check.go index ebf767f..d9f9024 100644 --- a/check.go +++ b/check.go @@ -59,6 +59,8 @@ type ReleaseRequest struct { // module and shared-file inputs. Independent nested modules form automatic // input boundaries; vendored files remain included. Referenced destinations // remain available for targeted inspection without expanding the input set. +// UntrackedFiles checks explicitly selected repository paths independently of +// those file inputs. type ValidationSpec struct { // SharedFiles adds repository-relative committed files to optional file // 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 // are off. 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 // does not modify repository contents and is disabled when false. GoFormat bool @@ -137,6 +143,8 @@ type GoValidationSpec struct { // 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 // 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 // Go checks and before AdditionalChecks. Its zero value disables formatting. // Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace @@ -189,6 +197,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request { FullVet: request.Validation.Go.FullVet, CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)), SharedFiles: copyStringSlice(request.Validation.SharedFiles), + UntrackedFiles: copyStringSlice(request.Validation.UntrackedFiles), GoFormat: request.Validation.GoFormat, Markdown: internalMarkdownSettings(request.Validation.Markdown), ModuleDistribution: validation.ModuleDistributionSettings{ diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 32be5f8..c533cd3 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -153,8 +153,9 @@ type Settings struct { type ValidationSettings struct { // SharedFiles selects committed files relative to the repository root. // Nil uses optional defaults; a non-nil empty slice selects no shared files. - SharedFiles []string `json:"shared_files"` - Go GoValidationSettings `json:"go,omitempty"` + SharedFiles []string `json:"shared_files"` + UntrackedFiles []string `json:"untracked_files,omitempty"` + Go GoValidationSettings `json:"go,omitempty"` // GoFormat checks the formatting of committed regular Go source files. // Omitted or false disables the check. GoFormat bool `json:"go_format,omitempty"` @@ -201,6 +202,7 @@ type DeliverySettings struct { Owner string `json:"owner"` Repository string `json:"repository"` TokenEnv string `json:"token_env,omitempty"` + TokenFile string `json:"token_file,omitempty"` } // BuildSettings selects the Go application package, artifact name, and targets. diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 3a243de..a7ca2a2 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -181,8 +181,9 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec return releasekit.ValidationSpec{} } result := releasekit.ValidationSpec{ - SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles), - GoFormat: settings.GoFormat, + SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles), + UntrackedFiles: copyStringsPreservingEmpty(settings.UntrackedFiles), + GoFormat: settings.GoFormat, ModuleDistribution: releasekit.ModuleDistributionValidationSpec{ WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles, 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) return 2 } - destination, status := deliveryDestination(settings, stderr) + destination, status := deliveryDestination(invocation.Repository, settings, stderr) if status != 0 { 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. // 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 { - destination, status := deliveryDestination(settings, stderr) + destination, status := deliveryDestination(invocation.Repository, 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) { +func deliveryDestination(repositoryDir string, settings cli.Settings, stderr io.Writer) (releasekit.GiteaDestination, int) { delivery := settings.Delivery 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") @@ -327,7 +328,23 @@ func deliveryDestination(settings cli.Settings, stderr io.Writer) (releasekit.Gi tokenEnv = "RELEASEKIT_GITEA_TOKEN" } 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) return releasekit.GiteaDestination{}, 2 } diff --git a/cmd/releasekit/token_file_test.go b/cmd/releasekit/token_file_test.go new file mode 100644 index 0000000..919aabc --- /dev/null +++ b/cmd/releasekit/token_file_test.go @@ -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") + } + }) + } +} diff --git a/docs/cli.md b/docs/cli.md index 62ad0c6..dffced8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -174,8 +174,9 @@ Deliver a tagged source release to Gitea: go run ./cmd/releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION ``` -`deliver` requires a `delivery` profile object and its configured token -environment variable, which defaults to `RELEASEKIT_GITEA_TOKEN`. It verifies +`deliver` requires a `delivery` profile object and a token loaded from its +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, then creates or finds the release page and reports delivery state separately from source publication. Historical lightweight tags and detached checkouts diff --git a/docs/config.md b/docs/config.md index b911e0e..70b4687 100644 --- a/docs/config.md +++ b/docs/config.md @@ -16,7 +16,7 @@ The current profile fields are: | `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. | | `validation` | No | Optional shared checks selected by ReleaseKit. | | `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; 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, 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 -`token_env`. Its values are required when running `deliver`, but credentials are -not required for `check`, `publish`, or `build`. `token_env` names the -environment variable containing the Gitea token; it defaults to -`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and -passes its value explicitly to the library operation. +`token_env` and `token_file`. `token_env` names the environment variable +containing the Gitea token and defaults to `RELEASEKIT_GITEA_TOKEN`. A nonblank +environment value takes precedence. When it is unset or blank, `token_file` +selects a plain token-containing file; relative paths resolve from the Git +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 effective push URL during `publish`, before candidate validation. It applies diff --git a/docs/consumers/check.md b/docs/consumers/check.md index c527072..367577d 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -98,6 +98,13 @@ The `Candidate` supplies both `RepositoryDir` and `ModuleDir`, plus the declared module-local product checks. ReleaseKit calls `AdditionalChecks` once after 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 ReleaseKit's optional file checks without enabling any checks. A nil slice selects the optional defaults `COPYRIGHT`, `LICENSE`, and diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index 4b81765..562d192 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -77,11 +77,36 @@ git checkout --detach 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 -`RELEASEKIT_GITEA_TOKEN`. It verifies the local checkout and hosted tag before +The CLI resolves the token for delivery using the configured environment +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 hosted body. A retry accepts an identical page and matching attachments, adds 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 `docs/releases/.md`, and the hosted page is keyed by its complete tag. diff --git a/docs/internal/validation.md b/docs/internal/validation.md index 8e4e925..eff7550 100644 --- a/docs/internal/validation.md +++ b/docs/internal/validation.md @@ -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 and line numbers remain cached. Each declaration path receives findings from 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 exclusions with path-boundary matching, then requests local Git sizes for the remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are diff --git a/docs/release.md b/docs/release.md index 829af47..a10e280 100644 --- a/docs/release.md +++ b/docs/release.md @@ -14,6 +14,15 @@ succeeded. CI and consumer installation are not completion checks. ## 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 local installation the active `go` command for preparation, building the release executable, and candidate validation: @@ -150,7 +159,15 @@ git checkout --detach "vX.Y.Z" GOTOOLCHAIN=local go mod download 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" +.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 diff --git a/internal/validation/diagnostic.go b/internal/validation/diagnostic.go index bd6cd5f..e7aea60 100644 --- a/internal/validation/diagnostic.go +++ b/internal/validation/diagnostic.go @@ -27,6 +27,8 @@ const ( MarkdownLinks Check = "markdown-links" // SharedFiles identifies a requested shared file that is unavailable. 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. diff --git a/internal/validation/untracked_files.go b/internal/validation/untracked_files.go new file mode 100644 index 0000000..c9db5bf --- /dev/null +++ b/internal/validation/untracked_files.go @@ -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} +} diff --git a/internal/validation/untracked_files_test.go b/internal/validation/untracked_files_test.go new file mode 100644 index 0000000..fdc6c10 --- /dev/null +++ b/internal/validation/untracked_files_test.go @@ -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) + } + } +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index f433ca1..2403418 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -21,6 +21,7 @@ type Settings struct { FullVet bool CompileTargets []Target SharedFiles []string + UntrackedFiles []string GoFormat bool Markdown *MarkdownSettings ModuleDistribution ModuleDistributionSettings @@ -134,7 +135,7 @@ func Run(ctx context.Context, request Request) (result error) { } } 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) if err != nil { var failure *Failure @@ -154,6 +155,9 @@ func Run(ctx context.Context, request Request) (result error) { if request.Settings.GoFormat { check = GoFormat } + if len(request.Settings.UntrackedFiles) != 0 { + check = UntrackedFiles + } return &Failure{Check: check, Cause: err} } 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 { readsBefore := tree.blobReads if err := checkGoFormat(ctx, tree); err != nil { diff --git a/releasekit.json b/releasekit.json index fb35cd6..9912e30 100644 --- a/releasekit.json +++ b/releasekit.json @@ -1,10 +1,14 @@ { "product": "ReleaseKit", "remote": "origin", + "validation": { + "untracked_files": [".releasekit/gitea-token"] + }, "delivery": { "base_url": "https://forge.maximumdirect.net", "owner": "base-layer", "repository": "releasekit", - "token_env": "RELEASEKIT_GITEA_TOKEN" + "token_env": "RELEASEKIT_GITEA_TOKEN", + "token_file": ".releasekit/gitea-token" } } diff --git a/token_validation_test.go b/token_validation_test.go new file mode 100644 index 0000000..9f47b43 --- /dev/null +++ b/token_validation_test.go @@ -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") + } + }) + } +} diff --git a/validation_error.go b/validation_error.go index a542c7b..68b4581 100644 --- a/validation_error.go +++ b/validation_error.go @@ -9,7 +9,8 @@ import ( // ValidationCheck identifies a shared validation check that failed. // 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 // ValidationFinding describes a failed check observation at an optional