Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitf534efeunknown_key

Wire workflow cache SAVE on job success

Wire workflow cache SAVE on job success

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 6, 2026Parent: f12113a
2 files changed+16013f534efe9caccb7148a3b4887619050c76351a402
2 changed files+160−13
Modifiedsrc/lib/actions/cache-action.ts+118−13View fileUnifiedSplit
11/**
2 * `gluecron/cache@v1`RESTORE-only cache action (v1 scope).
2 * `gluecron/cache@v1` — cache action with RESTORE (load) and SAVE sides.
33 *
4 * Looks up `workflow_run_cache` by (repoId, key, scope='repo') and unpacks
5 * the stored tar archive into `ctx.workspace/<path>`. If no exact key hit,
6 * tries each `restoreKeys` entry as a prefix match ordered by most-recently
7 * used. Sets `cache-hit` output to 'true' or 'false'.
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'.
88 *
9 * TODO (v2): cache SAVE on job success. The deferred design is for the
10 * runner to honor a `save-cache: true` flag emitted by this action and
11 * call a `saveCache(ctx, key, path)` helper at end-of-job. Implementing
12 * save inline here is error-prone (we'd need a post-hook) and the spec
13 * explicitly endorsed shipping restore-only for v1. Size cap logic is
14 * stubbed below so it's trivial to wire up later.
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.
1515 *
1616 * Failure tolerance: any error — DB miss, tar failure, unknown scope —
17 * results in `cache-hit: false` with exitCode 0. Caching is an optimization;
18 * losing it must never break a pipeline.
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.
1919 */
2020
2121import { and, eq, isNull, sql } from "drizzle-orm";
178178 }
179179}
180180
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 */
193export 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
181286export const cacheAction: ActionHandler = {
182287 name: "gluecron/cache",
183288 version: "v1",
Modifiedsrc/lib/workflow-runner.ts+42−0View fileUnifiedSplit
3131 loadSecretsContext,
3232 substituteSecrets,
3333} from "./workflow-secrets";
34import { saveCacheEntry } from "./actions/cache-action";
3435
3536// ---------------------------------------------------------------------------
3637// Tunables
479480 job: ParsedJob;
480481 jobOrder: number;
481482 checkoutDir: string;
483 repoId?: string;
482484 /** Per-run plaintext secrets. Default `{}` preserves the v1 contract
483485 * for callers that haven't been updated to plumb secrets through. */
484486 secrets?: Record<string, string>;
555557 const combinedLogs = truncate(logParts.join("\n"), JOB_LOG_CAP_BYTES);
556558 const status = anyFailed ? "failure" : "success";
557559
560 // Post-job cache save: mirrors GitHub Actions' post-job hook behaviour.
561 // Only runs when every step succeeded — partial saves are useless and could
562 // poison future restores with an incomplete workspace snapshot.
563 if (!anyFailed && opts.repoId) {
564 for (const step of steps) {
565 const uses = typeof step.uses === "string" ? step.uses : "";
566 const isCacheStep =
567 uses === "actions/cache@v3" ||
568 uses === "actions/cache@v2" ||
569 uses === "actions/cache@v1" ||
570 uses === "actions/cache" ||
571 uses.startsWith("gluecron/cache");
572 if (!isCacheStep) continue;
573
574 const w = step.with && typeof step.with === "object" && !Array.isArray(step.with)
575 ? (step.with as Record<string, unknown>)
576 : {};
577 const cacheKey = typeof w.key === "string" ? w.key : "";
578 const rawPath = w.path ?? w.paths;
579 const cachePaths = Array.isArray(rawPath)
580 ? rawPath.filter((p): p is string => typeof p === "string")
581 : typeof rawPath === "string"
582 ? [rawPath]
583 : [];
584
585 if (!cacheKey || cachePaths.length === 0) continue;
586
587 try {
588 await saveCacheEntry(opts.repoId, cacheKey, cachePaths, checkoutDir);
589 } catch (err) {
590 // Fail-open: log but never let a save failure surface as a job failure.
591 console.warn(
592 `[workflow-runner] cache save failed for key=${cacheKey}:`,
593 err instanceof Error ? err.message : err
594 );
595 }
596 }
597 }
598
558599 if (jobId) {
559600 try {
560601 await db
747788 job,
748789 jobOrder: i,
749790 checkoutDir,
791 repoId: run.repositoryId,
750792 secrets: runSecrets,
751793 });
752794 if (!result.success) {
753795