Read delivery tokens from files and check selected paths before release

This commit is contained in:
2026-11-01 15:39:36 +00:00
parent 6650b639e1
commit 792bfc4eca
17 changed files with 361 additions and 23 deletions

View File

@@ -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{

View File

@@ -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.

View File

@@ -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
}

View 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")
}
})
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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/<version>.md`, and the hosted page is keyed by its complete tag.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View 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}
}

View 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)
}
}
}

View File

@@ -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 {

View File

@@ -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"
}
}

38
token_validation_test.go Normal file
View 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")
}
})
}
}

View File

@@ -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