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
| Package | Responsibility |
|---|---|
@actup/core | Engine: 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-github | GitHub / GitHub Enterprise provider (REST + GraphQL) |
@actup/provider-gitlab | GitLab provider |
@actup/provider-gitea | Gitea / Forgejo / Codeberg provider |
@actup/providers | buildRegistry: host → provider resolution, host-kind guessing, token selection, file-cache wrapping |
@actup/cli | actup 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():
- Discover —
discoverFiles(root, …)globs.github/workflows/*.{yml,yaml}andaction.{yml,yaml}/.github/actions/*/action.{yml,yaml}, plus any configuredscan.extraPaths, minusscan.ignore. Results are de-duplicated and path-sorted. - Parse —
extractUsesFromYaml(text)regex-scans each line foruses:and records aUsesLocation: 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 toref: nulland are skipped. - Resolve — for each ref, the host is
ref.host ?? config.defaultHost.resolveProvider(host)(frombuildRegistry) returns a cache-wrapped provider;provider.getVersions(id)returns aResult<VersionSet>. Results are memoized per repo key for the scan. Anullprovider records aconfigerror; a provider that throws is caught and recorded as anetworkerror — the scan never aborts. - Evaluate —
evaluateRef({ ref, set, policy })is pure (no I/O): it classifies the ref into aFinding(upToDate,outdated,floating,pinned,pinnable,unpinnable,unresolvable) using the effectivePolicy(policyForapplies globoverrides, last-match-wins, over the base policy). Floating branches and non-version refs that need a SHA trigger an extraprovider.resolveRefToShacall in the engine (kept out of the pure evaluator). - Apply (
update/pinonly) —apply(result, dryRun)turnsoutdated/pinnable/unpinnablefindings intoSpanEdits andapplySpanEditsrewrites 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, thenrenameover 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-specificclassifyStatus(e.g.classify429); - typed mapping of failures: connection error →
network;401→auth;404→notFound; 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:
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/rateLimit → 3 and notFound/parse/config/cache → 2. 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.