Skip to content

Architecture

actup-ts is a Bun workspace + Turborepo monorepo. The engine is provider-neutral and does no networking itself; concrete hosts live in @actup/provider-* and are wired together by @actup/providers.

Package map

PackageResponsibility
@actup/coreEngine: discovery, YAML uses: parsing, version model, resolution, policy, config (zod), SQLite cache, the httpJson boundary, the Result error model. No host-specific networking.
@actup/provider-githubGitHub / GitHub Enterprise provider (REST + GraphQL)
@actup/provider-gitlabGitLab provider
@actup/provider-giteaGitea / Forgejo / Codeberg provider
@actup/providersbuildRegistry: host → provider resolution, host-kind guessing, token selection, file-cache wrapping
@actup/cliactup binary: arg parsing, command dispatch, output rendering, exit-code classification

Packages use Node-style subpath imports (#* mapped to ./src/* per each package's imports map) and workspace protocol deps.

Data flow

A check/update/pin run executes the engine pipeline in @actup/core's scan():

  1. DiscoverdiscoverFiles(root, …) globs .github/workflows/*.{yml,yaml} and action.{yml,yaml} / .github/actions/*/action.{yml,yaml}, plus any configured scan.extraPaths, minus scan.ignore. Results are de-duplicated and path-sorted.
  2. ParseextractUsesFromYaml(text) regex-scans each line for uses: and records a UsesLocation: 1-based line/col, raw value, absolute [start, end) byte span, the quote char, any trailing # comment, and line-end offset. This span metadata is what makes edits surgical. Local (./…) and Docker (docker://…) refs parse to ref: null and are skipped.
  3. Resolve — for each ref, the host is ref.host ?? config.defaultHost. resolveProvider(host) (from buildRegistry) returns a cache-wrapped provider; provider.getVersions(id) returns a Result<VersionSet>. Results are memoized per repo key for the scan. A null provider records a config error; a provider that throws is caught and recorded as a network error — the scan never aborts.
  4. EvaluateevaluateRef({ ref, set, policy }) is pure (no I/O): it classifies the ref into a Finding (upToDate, outdated, floating, pinned, pinnable, unpinnable, unresolvable) using the effective Policy (policyFor applies glob overrides, last-match-wins, over the base policy). Floating branches and non-version refs that need a SHA trigger an extra provider.resolveRefToSha call in the engine (kept out of the pure evaluator).
  5. Apply (update/pin only) — apply(result, dryRun) turns outdated / pinnable / unpinnable findings into SpanEdits and applySpanEdits rewrites only those byte spans, so unrelated lines, quoting and comments are byte-identical. Non-dry-run writes are atomic: write <file>.actup-<pid>-<ts>.tmp, then rename over the original; the temp file is removed if the rename fails.

The CLI then renders (human/json/sarif, optional annotations) and returns classifyExit(result) — see exit codes.

Span-surgical edits

UsesLocation carries absolute offsets (span, lineEnd, commentAnchor) and the surrounding quote. editFor builds a replacement for the value (and, for pin/unpin, the trailing comment), stepping back over the opening quote so quoted values don't get an orphaned quote. Because edits are offset slices, the rest of the file — including YAML anchors, indentation and comments — is preserved exactly.

SQLite cache

FileCache (bun:sqlite) stores one row per repo key (fetched_at, JSON payload of tags/branches/default branch), with WAL journaling. get() enforces an absolute TTL expiry instant (config.fetch.cacheTtl seconds) and zod-validates the stored payload. If the cache path is unwritable or corrupt, it falls back to an in-memory database so a bad cache degrades gracefully instead of aborting. CachingProvider wraps every provider: cache hit → serve from cache; miss + offline → a cache error (reported as unresolvable); miss + online → fetch, then persist all tags so offline non-semver resolution still works.

The httpJson boundary

Every provider's network access funnels through one function, httpJson(schema, opts) in @actup/core:

  • a single fetch, with the provider supplying URL, headers, method and any host-specific classifyStatus (e.g. classify429);
  • typed mapping of failures: connection error → network; 401auth; 404notFound; other non-OK → network; non-JSON body → network;
  • zod validation of the response shape (mismatch → network).

Providers therefore never throw across the boundary and never hand back unvalidated data.

The Result error model

@actup/core uses an explicit, typed Result instead of exceptions at boundaries:

ts
type ActupError =
	| { kind: 'parse'; message: string }
	| { kind: 'config'; message: string }
	| { kind: 'network'; message: string; host?: string }
	| { kind: 'auth'; message: string; host?: string }
	| { kind: 'rateLimit'; message: string; host?: string; resetAt?: number }
	| { kind: 'notFound'; message: string }
	| { kind: 'cache'; message: string };

type Result<T> = { ok: true; value: T } | { ok: false; error: ActupError };

exitCodeForError maps network/auth/rateLimit3 and notFound/parse/config/cache2. classifyExit in the CLI combines errors and findings: a single network-class error short-circuits to 3, otherwise unresolvable/error → 2, actionable → 1, else 0. Errors influence the exit code directly so an error without a paired finding can never produce a false 0.

The only sanctioned as cast in the codebase is the Sha40 smart-constructor (sha40()), reached only after a 40-hex-char regex check, so the branded-type invariant always holds.

See Contributing to add a provider.