File Fingerprinting & Up-to-Date Checks for Actions#

Related: #241 (state persistence), PR #272 (state implementation)

1. Summary#

Add file-based fingerprinting so actions declaring sources (and optionally generates) can be automatically skipped when their input files haven’t changed and their output files haven’t been externally modified since the last successful run. Fingerprint hashes are stored via the existing state persistence system (PR #272).

This is Option A from the feature spec – the most ergonomic approach for common build, deploy, and code-generation workflows.

Key invariant: An action is up-to-date only when both source inputs AND generated outputs match their stored checksums. Manual edits to generated files trigger re-execution.


2. Architecture Decisions#

DecisionChoiceRationale
New packagepkg/fingerprint/Keeps hash/glob/staleness logic testable independently of action/state packages
Schema additionssources and generates on Action structNatural extension of existing action fields; no new YAML nesting
State key format__fingerprint:<action>:sources and __fingerprint:<action>:generatesUses existing map[string]*Entry with a reserved namespace prefix; two keys per action
Hash algorithmSHA-256 of sorted file list + contentsReuses existing crypto/sha256 usage in fileprovider; deterministic ordering
Glob librarydoublestar (or stdlib filepath.Glob + filepath.WalkDir)gobwas/glob is already a dep but doesn’t expand against filesystem; doublestar supports ** recursive globs
Skip reasonNew SkipReasonUpToDate constantDistinguished from SkipReasonCondition in output/telemetry
Evaluation pointAfter when: check, before input resolution in executor.goSame pattern as existing skip logic; fingerprint check skipped if when: already skips
Layers affectedpkg/action (types + executor), new pkg/fingerprint, pkg/state (key convention), CLI outputMinimal surface area

3. Task Breakdown#

#TaskFile(s)ComplexityDependencies
1Add Sources/Generates fields to Action structpkg/action/types.goS
2Add SkipReasonUpToDate constantpkg/action/types.goS
3Create pkg/fingerprint/ package: glob expansion, hashing, staleness checkpkg/fingerprint/fingerprint.go, hash.go, glob.goM
4Add fingerprint state key convention and helpers to read/write fingerprints from statepkg/fingerprint/state.goS#3
5Integrate fingerprint check into action executor (skip if up-to-date)pkg/action/executor.goM#1, #2, #3, #4
6Post-execution: store new fingerprints (sources + generates) on successful action completionpkg/action/executor.goS#5
7Add --force / --no-cache flag to bypass fingerprint checkspkg/cmd/scafctl/run/solution.go, executor optionsS#5
8Update action YAML parsing/validation (warn on sources without state enabled, validate glob syntax)pkg/action/types.go, pkg/action/validate.goS#1
9Wire doublestar dependency (if needed) or implement recursive glob with stdlibgo.modS#3
10Unit tests for pkg/fingerprint/pkg/fingerprint/*_test.goM#3, #4
11Unit tests for executor fingerprint skip logicpkg/action/executor_test.goM#5, #6
12Integration test: solution with sources/generatestests/integration/solutions/M#1–#6
13CLI output integration (display “skipped (up-to-date)” in action table)pkg/action/output.goS#2
14MCP tool support (expose fingerprint info in state inspection)pkg/mcp/tools_state.goS#4
15Documentation and examplesdocs/, examples/SAll

4. Interface Design#

4.1 Core Types#

// pkg/fingerprint/fingerprint.go

package fingerprint

import "context"

// Result holds the comparison outcome for an action's sources and generates.
type Result struct {
    // Stale is true when the action should be re-executed.
    Stale bool

    // CurrentHash is the SHA-256 hash of current source files.
    CurrentHash string

    // PreviousHash is the stored hash of sources from last successful run (empty on first run).
    PreviousHash string

    // GeneratesHash is the SHA-256 hash of current generated files.
    GeneratesHash string

    // PreviousGeneratesHash is the stored hash of generates from last successful run.
    PreviousGeneratesHash string

    // Reason provides a human-readable explanation.
    Reason Reason
}

// Reason categorizes why an action is stale or up-to-date.
type Reason string

const (
    ReasonFirstRun          Reason = "first run"
    ReasonSourcesChanged    Reason = "sources changed"
    ReasonGeneratesModified Reason = "generates modified"
    ReasonGeneratesMissing  Reason = "generates missing"
    ReasonUpToDate          Reason = "up-to-date"
)

4.2 Checker Interface#

// pkg/fingerprint/fingerprint.go

// Checker evaluates whether an action's sources/generates have changed since the last run.
type Checker interface {
    // Check compares current file fingerprints against stored state.
    // baseDir is the working directory for resolving relative globs.
    Check(ctx context.Context, actionName string, sources, generates []string, baseDir string) (*Result, error)

    // Record stores the current fingerprint (sources + generates) after a successful action.
    Record(ctx context.Context, actionName string, sources, generates []string, baseDir string) error
}

4.3 Hashing#

// pkg/fingerprint/hash.go

// HashFiles computes a deterministic SHA-256 hash over the contents of all
// files matching the given glob patterns, resolved relative to baseDir.
// Files are sorted lexicographically before hashing for determinism.
// Returns empty string and nil error if patterns is empty.
func HashFiles(baseDir string, patterns []string) (string, error)

4.4 Glob Expansion#

// pkg/fingerprint/glob.go

// ExpandGlobs resolves glob patterns against the filesystem, returning
// a sorted, deduplicated list of matching file paths (relative to baseDir).
// Supports ** for recursive matching.
func ExpandGlobs(baseDir string, patterns []string) ([]string, error)

Empty source globs#

A sources pattern that matches no files (for example a typo, or a file that does not exist yet) is tolerated rather than fatal – mirroring the sources semantics of build tools like go-task:

  • Partial no-match (some patterns match, some do not): the action is fingerprinted on the files that did match. The non-matching patterns are reported and surface as a low-key stderr hint (useful for catching typos), but idempotency is preserved. This fixes the silent correctness bug where a single bad pattern disabled caching for the whole action.
  • Total no-match (every source pattern matches nothing): there are no trackable inputs, so the action is treated as always stale (reason no source files) and re-runs on every invocation. A stderr warning is emitted (sources matched no files (fingerprint disabled)). Caching is effectively disabled for that action until at least one source pattern matches.
  • Invalid or unsafe patterns (malformed globs, absolute paths, .. traversal) remain a hard error (ErrPatternInvalid).

Note that generates is stricter than sources: a declared output that does not exist (partial or total no-match) always marks the action stale, since a missing product means the action must run to produce it.

ExpandGlobs returns ErrNoMatches only for the total no-match case; callers that need to distinguish partial from total (and warn accordingly) use ExpandGlobsReport / HashFilesReport, which never return ErrNoMatches and instead report EmptyPatterns and AllEmpty. All warnings are written to stderr so they never corrupt structured output (-o json).

4.5 State Helpers#

// pkg/fingerprint/state.go

// SourcesKey returns the state entry key for an action's source fingerprint.
// Format: "__fingerprint:<actionName>:sources"
func SourcesKey(actionName string) string

// GeneratesKey returns the state entry key for an action's generates fingerprint.
// Format: "__fingerprint:<actionName>:generates"
func GeneratesKey(actionName string) string

// LoadHash retrieves a previously stored hash from state.
func LoadHash(stateData *state.Data, key string) string

// SaveHash stores a hash into state data for persistence.
func SaveHash(stateData *state.Data, key, hash string)

4.6 Action Struct Additions#

// In pkg/action/types.go, added to Action struct:

// Sources declares glob patterns for input files this action depends on.
// When provided (and state is enabled), the action is skipped if source
// files haven't changed and generated files haven't been externally modified
// since the last successful run.
Sources []string `json:"sources,omitempty" yaml:"sources,omitempty" doc:"Glob patterns for input file dependencies" maxItems:"100"`

// Generates declares glob patterns for output files this action produces.
// If all generated files exist and their checksums match the stored hashes
// from the last successful run, the action is skipped. Manual edits to
// generated files will trigger re-execution.
Generates []string `json:"generates,omitempty" yaml:"generates,omitempty" doc:"Glob patterns for output files" maxItems:"100"`

// Fingerprint controls fingerprint behavior for up-to-date checks.
// By default (scope: all), both file hashes and resolved input hashes are checked.
// Set scope to "files" to skip input hashing when inputs contain volatile values.
// Requires sources to be set.
Fingerprint *FingerprintConfig `json:"fingerprint,omitempty" yaml:"fingerprint,omitempty" doc:"Fingerprint configuration for up-to-date checks"`

FingerprintConfig#

// FingerprintScope controls which data is included in up-to-date fingerprint checks.
type FingerprintScope string // "all" (default) | "files"

// FingerprintConfig controls fingerprint behavior for an action.
type FingerprintConfig struct {
    Scope FingerprintScope `json:"scope,omitempty" yaml:"scope,omitempty"`
}
ScopeBehavior
all (default)Check file hashes (Phase 1) AND resolved input hashes (Phase 2). Both must match for skip.
filesCheck file hashes only. Input changes are ignored. Useful when inputs contain volatile values (timestamps, build IDs) that change every run.

When scope: files, the executor skips Phase 2 (input hashing) and Record() does not store an inputs hash. This prevents ghost state (stored hashes that are never compared).

Lint rule: fingerprint-without-sources (warning) fires when fingerprint is set but sources is empty, since fingerprinting has no effect without source files.

4.7 New Skip Reason#

// In pkg/action/types.go:

// SkipReasonUpToDate indicates the action was skipped because its source
// fingerprint and generates fingerprint both match stored state.
SkipReasonUpToDate SkipReason = "up-to-date"

5. Staleness Check Logic#

An action is up-to-date (skip) if and only if ALL conditions hold:

1. Previous state exists for this action
2. Sources hash == stored sources hash         (inputs unchanged)
3. All generates files exist                   (outputs present)
4. Generates hash == stored generates hash     (outputs not modified externally)

If ANY condition fails, the action is stale and executes:

Condition FailedReasonBehavior
No previous statefirst runExecute action
Sources hash mismatchsources changedExecute action
Generated files missinggenerates missingExecute action
Generates hash mismatchgenerates modifiedExecute action
All matchup-to-dateSkip action

Scenario: Scaffold then manually modify#

  1. First run: action executes, stores hashes of both sources and generates
  2. User manually edits a generated file
  3. Re-run: sources hash matches, but generates hash does NOT match stored value
  4. Result: stale with reason generates modified – action re-executes

This ensures correctness: the system never assumes generated output is valid after external modification.

Evaluation Order in Executor#

1. Check when: condition → skip with SkipReasonCondition if false
2. Check fingerprint (sources + generates) → skip with SkipReasonUpToDate if up-to-date
3. Resolve inputs
4. Execute action
5. On success: record new fingerprints (sources + generates)

6. Error Handling#

Sentinel Errors#

// pkg/fingerprint/errors.go

var (
    // ErrNoMatches is returned when a glob pattern matches zero files.
    ErrNoMatches = errors.New("glob pattern matched no files")

    // ErrPatternInvalid is returned for malformed glob patterns.
    ErrPatternInvalid = errors.New("invalid glob pattern")
)

Wrapping Strategy#

  • Glob expansion: fmt.Errorf("expanding sources for action %q: %w", name, err)
  • Hash computation: fmt.Errorf("computing fingerprint for action %q: %w", name, err)
  • State read/write: fmt.Errorf("fingerprint state for action %q: %w", name, err)

Behavior on Error#

Error TypeBehaviorRationale
Invalid glob patternFail actionConfiguration error – user must fix
Permission denied on fileRun action (fail-open) + warningTransient; safer to re-run than skip
State unavailable / disabledRun actionFirst-run equivalent; no skip possible
Empty sources (no patterns)Run actionNo fingerprint possible; normal execution

7. Testing Strategy#

TypeScopeFile(s)
UnitGlob expansion: **/*.go, *, nested dirs, symlinks, no-matchpkg/fingerprint/glob_test.go
UnitHash determinism: same files same hash, different differentpkg/fingerprint/hash_test.go
UnitStaleness logic: first run, sources changed, generates modified, generates missing, up-to-datepkg/fingerprint/fingerprint_test.go
UnitState key helpers: format, load, savepkg/fingerprint/state_test.go
UnitExecutor skip logic: sources unchanged -> skip, changed -> runpkg/action/executor_test.go
UnitAction validation: sources without state, invalid globspkg/action/validate_test.go
BenchmarkHashFiles with 100/1000/10000 filespkg/fingerprint/hash_test.go
BenchmarkExpandGlobs with recursive patterns in deep treepkg/fingerprint/glob_test.go
Integration (CLI)scafctl run solution with sources – verify skip on re-runtests/integration/
Integration (Solution)YAML fixture with sources/generates, manual modification scenariotests/integration/solutions/fingerprint/
E2Etask test:e2e passFull suite

All tests use table-driven patterns with testify/assert. Tests create temp directories with known file contents to verify hash behavior deterministically.


8. Documentation#

AssetLocationContent
Design docdocs/design/state/fingerprinting.md (this file)Architecture, staleness logic, interface contracts
Tutorialdocs/tutorials/fingerprinting-tutorial.mdStep-by-step: package solution with sources/generates
Example solutionexamples/solutions/fingerprint-build.yamlGo build with source tracking
Provider reference updatedocs/tutorials/provider-reference.mdNote on exec + sources interplay
MCP toolpkg/mcp/tools_state.goExpose fingerprint entries in state inspection
CHANGELOGCHANGELOG.mdFeature entry under next version

9. Risks & Edge Cases#

RiskMitigation
Large file trees – hashing thousands of files could be slowBenchmark; consider parallel hashing with errgroup; document performance expectations
Symlinks – could cause infinite loops in glob expansionUse filepath.WalkDir with symlink detection; skip or follow based on flag
Race conditions – files change between hash and executionAccept as inherent limitation; document that fingerprinting is advisory, not atomic
State disabled – user adds sources but state is offValidation warning at parse time; action runs normally (no-op fingerprint)
Empty glob match – pattern matches nothing (e.g., typo)Return ErrNoMatches; fail the action so user notices misconfiguration
Cross-platform paths – Windows vs Unix glob separatorsUse filepath.ToSlash for state keys; test on both
.gitignore interaction – should ignored files be included?Yes – fingerprinting is explicit (user declares patterns); no implicit filtering
State schema migration – adding fingerprint keys to existing state filesNo migration needed – new keys simply appear in the existing Values map
--force escape hatch – user needs to bypass fingerprintAdd --force flag to run solution that sets a context value checked by executor
Manual edits to generates trigger re-runCorrect behavior – ensures generated output matches what the action would produce. Users who intentionally customize output should remove generates or use file conflict strategies
Generates hash computed at wrong timeRecord generates hash after the action completes and files are written to final state
Breaking change – new YAML fields are additiveNo break – sources/generates are optional; existing solutions unaffected

10. Example Usage#

apiVersion: scafctl/v1
kind: Solution
metadata:
  name: go-build
  version: 1.0.0

state:
  enabled: true
  backend:
    provider: file
    inputs:
      path: ".scafctl/state.json"

spec:
  resolvers:
    app-name:
      value: myapp

  workflow:
    actions:
      build:
        description: Build the Go binary
        provider: exec
        inputs:
          command:
            tmpl: "go build -o dist/{{ .app-name }} ./cmd/{{ .app-name }}"
        sources:
          - "cmd/**/*.go"
          - "pkg/**/*.go"
          - "internal/**/*.go"
          - go.mod
          - go.sum
        generates:
          - "dist/myapp"

      test:
        description: Run unit tests
        provider: exec
        inputs:
          command: go test ./...
        sources:
          - "**/*_test.go"
          - "pkg/**/*.go"
          - "internal/**/*.go"

First run: Both actions execute; fingerprints stored in .scafctl/state.json.

Second run (no changes): Both actions skipped with reason up-to-date.

After editing pkg/server/handler.go: Both actions re-execute (sources changed).

After manually editing dist/myapp (unlikely but illustrative): build re-executes (generates modified), test unaffected (no generates declared).


11. Input Hashing (Resolved Inputs)#

Problem#

File fingerprinting alone is insufficient when resolver-driven inputs change but source/template files remain unchanged. For example, a Go template config.yaml.tpl stays the same, but a resolver changes environment from "staging" to "production". The file-based fingerprint sees no change and skips the action – producing stale output.

Solution: Two-Phase Fingerprint Check#

The fingerprint check is split into two phases to avoid unnecessary (and potentially expensive) input resolution when files have already changed:

Phase 1 (CheckFiles) -- before input resolution:
  1. Hash source files
  2. Hash generated files
  3. Compare against stored hashes
  4. If files changed -> stale (skip Phase 2, proceed to execute)

Phase 2 (CheckInputs) -- after input resolution:
  1. Hash resolved inputs (JSON-serialized, SHA-256)
  2. Compare against stored inputs hash
  3. If inputs changed -> stale (proceed to execute)
  4. If inputs unchanged -> up-to-date (skip action)

Staleness Matrix (Updated)#

SourcesGeneratesInputsResultReason
No previous stateStalefirst run
ChangedStalesources changed
MatchMissingStalegenerates missing
MatchChangedStalegenerates modified
MatchMatchChangedStaleinputs changed
MatchMatchMatchUp-to-dateup-to-date
MatchMatchBoth emptyUp-to-dateNo inputs declared

API Changes#

// pkg/fingerprint/fingerprint.go

const ReasonInputsChanged Reason = "inputs changed"

// Result now includes:
type Result struct {
    // ... existing fields ...
    InputsHash         string  // SHA-256 of current resolved inputs
    PreviousInputsHash string  // Stored hash from last run
}

// Check is split into two methods:
func (c *Checker) CheckFiles(actionName string, sources, generates []string, baseDir string) (*Result, error)
func (c *Checker) CheckInputs(actionName string, resolvedInputs map[string]any) (*Result, error)

// Record now accepts resolved inputs:
func (c *Checker) Record(actionName string, sources, generates []string, baseDir string, resolvedInputs map[string]any) error
// pkg/fingerprint/hash.go

// HashInputs computes a deterministic SHA-256 hash of resolved action inputs.
// Uses JSON marshaling for deterministic serialization (Go's encoding/json
// sorts map keys). Returns empty string for nil or empty inputs.
func HashInputs(inputs map[string]any) (string, error)
// pkg/fingerprint/state.go

// State key format: "__fingerprint:<action>:inputs"
func inputsKey(actionName string) string
func LoadInputsHash(stateData *state.Data, actionName string) string
// SaveHashes now stores inputs hash alongside sources and generates hashes.
func SaveHashes(stateData *state.Data, actionName, sourcesHash, generatesHash, inputsHash string)

Executor Integration#

In pkg/action/executor.go, the fingerprint check is restructured:

// Phase 1: File-based check (cheap, before input resolution)
fpResult, err := fpChecker.CheckFiles(actionName, action.Sources, action.Generates, cwd)
if fpResult.Stale {
    // Files changed -- must re-execute, resolve inputs and run
    resolvedInputs := resolveInputs(...)
    callProvider(...)
    fpChecker.Record(actionName, sources, generates, cwd, resolvedInputs)
    return
}

// Phase 2: Input-based check (after resolver evaluation)
resolvedInputs := resolveInputs(...)
inputResult, err := fpChecker.CheckInputs(actionName, resolvedInputs)
if inputResult.Stale {
    // Inputs changed -- re-execute
    callProvider(...)
    fpChecker.Record(actionName, sources, generates, cwd, resolvedInputs)
    return
}

// Both files and inputs match -- skip
skip(SkipReasonUpToDate)

Edge Cases#

  • No inputs declared: Both current and previous inputs hashes are empty – treated as up-to-date (inputs are not a factor when none are configured).
  • First run with inputs: No previous hash exists – action executes and stores the hash.
  • Non-deterministic inputs: If inputs contain timestamps or random values, they will change on every run, effectively disabling the skip optimization for that action.