CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
cache-action.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| abfa9ad | 1 | /** |
| f534efe | 2 | * `gluecron/cache@v1` — cache action with RESTORE (load) and SAVE sides. |
| abfa9ad | 3 | * |
| f534efe | 4 | * RESTORE: looks up `workflow_run_cache` by (repoId, key, scope='repo') and |
| 5 | * unpacks the stored tar archive into `ctx.workspace/<path>`. If no exact key | |
| 6 | * hit, tries each `restoreKeys` entry as a prefix match ordered by | |
| 7 | * most-recently used. Sets `cache-hit` output to 'true' or 'false'. | |
| abfa9ad | 8 | * |
| f534efe | 9 | * SAVE: called by the workflow runner after a job's steps all succeed. |
| 10 | * Tarballs the `path` list relative to `workdir` and upserts the archive into | |
| 11 | * `workflow_run_cache`. On key conflict the existing row is replaced so the | |
| 12 | * runner always ends up with the freshest tarball for a given key. Size cap is | |
| 13 | * enforced before the DB write; oversize payloads are logged and silently | |
| 14 | * dropped so CI never fails due to cache infrastructure. | |
| abfa9ad | 15 | * |
| 16 | * Failure tolerance: any error — DB miss, tar failure, unknown scope — | |
| f534efe | 17 | * results in `cache-hit: false` (on restore) or a silent no-op (on save). |
| 18 | * Caching is an optimization; losing it must never break a pipeline. | |
| abfa9ad | 19 | */ |
| 20 | ||
| 21 | import { and, eq, isNull, sql } from "drizzle-orm"; | |
| 22 | import { mkdir } from "fs/promises"; | |
| 23 | import { join } from "path"; | |
| 24 | import { tmpdir } from "os"; | |
| 25 | import type { ActionHandler, ActionContext } from "../action-registry"; | |
| 26 | import { db } from "../../db"; | |
| 27 | import { workflowRunCache } from "../../db/schema"; | |
| 28 | ||
| 29 | // 100MB cap — reserved for the eventual save path. Kept here so both | |
| 30 | // halves of the action share the constant when v2 lands. | |
| 31 | export const MAX_CACHE_BYTES = 100 * 1024 * 1024; | |
| 32 | ||
| 33 | function parseInputs(ctx: ActionContext): { | |
| 34 | key: string; | |
| 35 | path: string; | |
| 36 | restoreKeys: string[]; | |
| 37 | } | null { | |
| 38 | const w = ctx.with || {}; | |
| 39 | const key = typeof w.key === "string" ? w.key : ""; | |
| 40 | const path = typeof w.path === "string" ? w.path : ""; | |
| 41 | const restoreKeysRaw = w.restoreKeys ?? w["restore-keys"]; | |
| 42 | const restoreKeys = Array.isArray(restoreKeysRaw) | |
| 43 | ? restoreKeysRaw.filter((k): k is string => typeof k === "string") | |
| 44 | : []; | |
| 45 | if (!key || !path) return null; | |
| 46 | return { key, path, restoreKeys }; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Find a cache row for this repo matching either the exact key or any | |
| 51 | * of the prefix restoreKeys. Returns the first hit (prefix matches are | |
| 52 | * ordered by `last_accessed_at DESC`). | |
| 53 | */ | |
| 54 | async function lookupCache( | |
| 55 | repoId: string, | |
| 56 | key: string, | |
| 57 | restoreKeys: string[] | |
| 58 | ): Promise< | |
| 59 | | { hit: true; id: string; content: Buffer; matchedKey: string; exact: boolean } | |
| 60 | | { hit: false } | |
| 61 | > { | |
| 62 | // Exact match first. | |
| 63 | const exact = await db | |
| 64 | .select() | |
| 65 | .from(workflowRunCache) | |
| 66 | .where( | |
| 67 | and( | |
| 68 | eq(workflowRunCache.repositoryId, repoId), | |
| 69 | eq(workflowRunCache.cacheKey, key), | |
| 70 | eq(workflowRunCache.scope, "repo"), | |
| 71 | isNull(workflowRunCache.scopeRef) | |
| 72 | ) | |
| 73 | ) | |
| 74 | .limit(1); | |
| 75 | const exactRow = exact[0]; | |
| 76 | if (exactRow) { | |
| 77 | return { | |
| 78 | hit: true, | |
| 79 | id: exactRow.id, | |
| 80 | content: normalizeBytea(exactRow.content), | |
| 81 | matchedKey: exactRow.cacheKey, | |
| 82 | exact: true, | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | // Prefix fallbacks (LRU order). | |
| 87 | for (const prefix of restoreKeys) { | |
| 88 | if (!prefix) continue; | |
| 89 | const rows = await db | |
| 90 | .select() | |
| 91 | .from(workflowRunCache) | |
| 92 | .where( | |
| 93 | and( | |
| 94 | eq(workflowRunCache.repositoryId, repoId), | |
| 95 | eq(workflowRunCache.scope, "repo"), | |
| 96 | isNull(workflowRunCache.scopeRef), | |
| 97 | sql`${workflowRunCache.cacheKey} LIKE ${prefix + "%"}` | |
| 98 | ) | |
| 99 | ) | |
| 100 | .orderBy(sql`${workflowRunCache.lastAccessedAt} DESC`) | |
| 101 | .limit(1); | |
| 102 | const row = rows[0]; | |
| 103 | if (row) { | |
| 104 | return { | |
| 105 | hit: true, | |
| 106 | id: row.id, | |
| 107 | content: normalizeBytea(row.content), | |
| 108 | matchedKey: row.cacheKey, | |
| 109 | exact: false, | |
| 110 | }; | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | return { hit: false }; | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * `content` arrives as a Buffer, Uint8Array, or (if the driver ran through | |
| 119 | * text serialization) a base64/hex string. Normalize to Buffer. | |
| 120 | */ | |
| 121 | function normalizeBytea(raw: unknown): Buffer { | |
| 122 | if (Buffer.isBuffer(raw)) return raw; | |
| 123 | if (raw instanceof Uint8Array) return Buffer.from(raw); | |
| 124 | if (typeof raw === "string") { | |
| 125 | // Postgres `bytea` text encoding can be `\x…` hex. Handle defensively. | |
| 126 | if (raw.startsWith("\\x")) return Buffer.from(raw.slice(2), "hex"); | |
| 127 | // Otherwise assume base64 (matches workflow-artifacts convention). | |
| 128 | try { | |
| 129 | return Buffer.from(raw, "base64"); | |
| 130 | } catch { | |
| 131 | return Buffer.alloc(0); | |
| 132 | } | |
| 133 | } | |
| 134 | return Buffer.alloc(0); | |
| 135 | } | |
| 136 | ||
| 137 | async function unpackTar(content: Buffer, destDir: string): Promise<void> { | |
| 138 | await mkdir(destDir, { recursive: true }); | |
| 139 | // Write the archive to a tmp file then untar. Piping through stdin works | |
| 140 | // but a tmp file avoids subtle Bun subprocess stdin EAGAIN edge cases. | |
| 141 | const tmpPath = join( | |
| 142 | tmpdir(), | |
| 2c3ba6e | 143 | `gluecron-cache-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar` |
| abfa9ad | 144 | ); |
| 145 | await Bun.write(tmpPath, content); | |
| 146 | try { | |
| 147 | const proc = Bun.spawn(["tar", "-xf", tmpPath, "-C", destDir], { | |
| 148 | stdout: "pipe", | |
| 149 | stderr: "pipe", | |
| 150 | }); | |
| 151 | await proc.exited; | |
| 152 | } finally { | |
| 153 | try { | |
| 154 | await Bun.file(tmpPath).exists(); | |
| 155 | // Best-effort cleanup; ignore errors. | |
| a28cede | 156 | await import("fs/promises") |
| 157 | .then((fs) => fs.unlink(tmpPath)) | |
| 158 | .catch((err) => { | |
| 159 | console.warn( | |
| 160 | `[cache-action] tmpPath cleanup failed for ${tmpPath}:`, | |
| 161 | err instanceof Error ? err.message : err | |
| 162 | ); | |
| 163 | }); | |
| abfa9ad | 164 | } catch { |
| 165 | /* noop */ | |
| 166 | } | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | async function touchLastAccessed(id: string): Promise<void> { | |
| 171 | try { | |
| 172 | await db | |
| 173 | .update(workflowRunCache) | |
| 174 | .set({ lastAccessedAt: new Date() }) | |
| 175 | .where(eq(workflowRunCache.id, id)); | |
| 176 | } catch { | |
| 177 | // Non-fatal — LRU accuracy is best-effort. | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| f534efe | 181 | /** |
| 182 | * Pack `paths` (relative to `workdir`) into a gzip-compressed tar archive and | |
| 183 | * upsert it into `workflow_run_cache` under `key` for `repoId`. | |
| 184 | * | |
| 185 | * On key conflict the existing row is replaced. This mirrors how GitHub Actions | |
| 186 | * handles re-runs with the same key: the freshest content wins. | |
| 187 | * | |
| 188 | * The archive is written to a temp file first (avoids EAGAIN edge cases on | |
| 189 | * Bun's subprocess stdin pipe) then read back into a Buffer for the DB write. | |
| 190 | * Callers must treat any thrown error as non-fatal — caching failures must | |
| 191 | * never abort a pipeline. | |
| 192 | */ | |
| 193 | export async function saveCacheEntry( | |
| 194 | repoId: string, | |
| 195 | key: string, | |
| 196 | paths: string[], | |
| 197 | workdir: string | |
| 198 | ): Promise<void> { | |
| 199 | const validPaths = paths.filter((p) => typeof p === "string" && p.length > 0); | |
| 200 | if (!key || validPaths.length === 0) return; | |
| 201 | ||
| 202 | const tmpPath = join( | |
| 203 | tmpdir(), | |
| 204 | `gluecron-cache-save-${Date.now()}-${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}.tar.gz` | |
| 205 | ); | |
| 206 | ||
| 207 | try { | |
| 208 | const proc = Bun.spawn( | |
| 209 | ["tar", "-czf", tmpPath, "--", ...validPaths], | |
| 210 | { | |
| 211 | cwd: workdir, | |
| 212 | stdout: "pipe", | |
| 213 | stderr: "pipe", | |
| 214 | } | |
| 215 | ); | |
| 216 | await proc.exited; | |
| 217 | ||
| 218 | const file = Bun.file(tmpPath); | |
| 219 | const exists = await file.exists(); | |
| 220 | if (!exists) return; | |
| 221 | ||
| 222 | const arrayBuf = await file.arrayBuffer(); | |
| 223 | const content = Buffer.from(arrayBuf); | |
| 224 | ||
| 225 | // Drop oversized archives silently so a large workspace never blocks CI. | |
| 226 | if (content.byteLength > MAX_CACHE_BYTES) { | |
| 227 | console.warn( | |
| 228 | `[cache-action] save skipped: archive for key=${key} is ${content.byteLength} bytes, exceeds cap ${MAX_CACHE_BYTES}` | |
| 229 | ); | |
| 230 | return; | |
| 231 | } | |
| 232 | ||
| 233 | const contentHash = Buffer.from( | |
| 234 | await crypto.subtle.digest("SHA-256", content) | |
| 235 | ).toString("hex"); | |
| 236 | ||
| 237 | // Manual upsert: the unique index includes nullable `scope_ref` so | |
| 238 | // Postgres `ON CONFLICT (…, scope_ref)` won't fire when scope_ref IS NULL | |
| 239 | // (each NULL is distinct in a standard B-tree index). We do a targeted | |
| 240 | // UPDATE first; if zero rows were touched we INSERT instead. | |
| 241 | const now = new Date(); | |
| 242 | const existing = await db | |
| 243 | .select({ id: workflowRunCache.id }) | |
| 244 | .from(workflowRunCache) | |
| 245 | .where( | |
| 246 | and( | |
| 247 | eq(workflowRunCache.repositoryId, repoId), | |
| 248 | eq(workflowRunCache.cacheKey, key), | |
| 249 | eq(workflowRunCache.scope, "repo"), | |
| 250 | isNull(workflowRunCache.scopeRef) | |
| 251 | ) | |
| 252 | ) | |
| 253 | .limit(1); | |
| 254 | ||
| 255 | if (existing[0]) { | |
| 256 | await db | |
| 257 | .update(workflowRunCache) | |
| 258 | .set({ | |
| 259 | content, | |
| 260 | contentHash, | |
| 261 | sizeBytes: content.byteLength, | |
| 262 | lastAccessedAt: now, | |
| 263 | }) | |
| 264 | .where(eq(workflowRunCache.id, existing[0].id)); | |
| 265 | } else { | |
| 266 | await db.insert(workflowRunCache).values({ | |
| 267 | repositoryId: repoId, | |
| 268 | cacheKey: key, | |
| 269 | scope: "repo", | |
| 270 | scopeRef: null, | |
| 271 | content, | |
| 272 | contentHash, | |
| 273 | sizeBytes: content.byteLength, | |
| 274 | lastAccessedAt: now, | |
| 275 | }); | |
| 276 | } | |
| 277 | } finally { | |
| 278 | await import("fs/promises") | |
| 279 | .then((fs) => fs.unlink(tmpPath)) | |
| 280 | .catch(() => { | |
| 281 | // Best-effort cleanup. | |
| 282 | }); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| abfa9ad | 286 | export const cacheAction: ActionHandler = { |
| 287 | name: "gluecron/cache", | |
| 288 | version: "v1", | |
| 289 | async run(ctx): Promise<import("../action-registry").ActionResult> { | |
| 290 | // Every failure path below returns exitCode 0 with cache-hit=false so | |
| 291 | // the pipeline keeps flowing. This is load-bearing behaviour. | |
| 292 | try { | |
| 293 | const inputs = parseInputs(ctx); | |
| 294 | if (!inputs) { | |
| 295 | return { | |
| 296 | exitCode: 0, | |
| 297 | outputs: { "cache-hit": "false" }, | |
| 298 | stderr: "cache: missing required inputs `key` and `path` — skipping", | |
| 299 | }; | |
| 300 | } | |
| 301 | ||
| 302 | const result = await lookupCache( | |
| 303 | ctx.repoId, | |
| 304 | inputs.key, | |
| 305 | inputs.restoreKeys | |
| 306 | ); | |
| 307 | ||
| 308 | if (!result.hit) { | |
| 309 | return { | |
| 310 | exitCode: 0, | |
| 311 | outputs: { "cache-hit": "false" }, | |
| 312 | stdout: `cache miss for key=${inputs.key}`, | |
| 313 | }; | |
| 314 | } | |
| 315 | ||
| 316 | const dest = join(ctx.workspace, inputs.path); | |
| 317 | await unpackTar(result.content, dest); | |
| 318 | await touchLastAccessed(result.id); | |
| 319 | ||
| 320 | return { | |
| 321 | exitCode: 0, | |
| 322 | outputs: { | |
| 323 | "cache-hit": result.exact ? "true" : "false", | |
| 324 | "matched-key": result.matchedKey, | |
| 325 | }, | |
| 326 | stdout: `cache ${result.exact ? "hit" : "partial hit"} for key=${inputs.key} (matched=${result.matchedKey})`, | |
| 327 | }; | |
| 328 | } catch (err) { | |
| 329 | // Fail-open: swallow and report as miss. | |
| 330 | return { | |
| 331 | exitCode: 0, | |
| 332 | outputs: { "cache-hit": "false" }, | |
| 333 | stderr: | |
| 334 | "cache error (non-fatal): " + | |
| 335 | (err instanceof Error ? err.message : String(err)), | |
| 336 | }; | |
| 337 | } | |
| 338 | }, | |
| 339 | }; |