Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

upload-artifact-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.

upload-artifact-action.tsBlame171 lines · 2 contributors
abfa9adClaude1/**
2 * `gluecron/upload-artifact@v1` — persists files from the workspace as a
3 * named artifact attached to the current run.
4 *
5 * Wraps Agent 6's `uploadArtifact` helper. `with:` inputs:
6 * name: string (required) — artifact name, stored on the run
7 * path: string (required) — file or directory inside `ctx.workspace`
8 *
9 * Behaviour:
10 * - If `path` resolves to a single file, the file is uploaded as-is with
11 * contentType inferred from the extension.
12 * - If `path` resolves to a directory, the directory is tar-gz'd first and
13 * uploaded as `application/gzip`.
14 * - If the artifact helper module can't be imported (e.g. out-of-tree
15 * deployment) the step degrades gracefully to exitCode 0 with a stderr
16 * note — a missing upload must never fail the pipeline unrelated to it.
17 * - Other errors (missing file, oversize, DB failure) return exitCode 1.
18 */
19
20import { stat } from "fs/promises";
21import { basename, join } from "path";
22import { tmpdir } from "os";
23import type { ActionHandler, ActionContext } from "../action-registry";
24
25function parseInputs(
26 ctx: ActionContext
27): { name: string; path: string } | { error: string } {
28 const w = ctx.with || {};
29 const name = typeof w.name === "string" ? w.name.trim() : "";
30 const path = typeof w.path === "string" ? w.path.trim() : "";
31 if (!name) return { error: "upload-artifact: `name` is required" };
32 if (!path) return { error: "upload-artifact: `path` is required" };
33 return { name, path };
34}
35
36function guessContentType(filename: string): string {
37 const lower = filename.toLowerCase();
38 if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "application/gzip";
39 if (lower.endsWith(".gz")) return "application/gzip";
40 if (lower.endsWith(".zip")) return "application/zip";
41 if (lower.endsWith(".tar")) return "application/x-tar";
42 if (lower.endsWith(".json")) return "application/json";
43 if (lower.endsWith(".txt") || lower.endsWith(".log")) return "text/plain";
44 if (lower.endsWith(".xml")) return "application/xml";
45 if (lower.endsWith(".html")) return "text/html";
46 return "application/octet-stream";
47}
48
49/**
50 * Tar-gz a directory into a tmp file and return the buffered bytes. Cleans
51 * up the tmp file regardless of success.
52 */
53async function tarGzDirectory(dir: string): Promise<Buffer> {
54 const tmpPath = join(
55 tmpdir(),
2c3ba6ecopilot-swe-agent[bot]56 `gluecron-artifact-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar.gz`
abfa9adClaude57 );
58 try {
59 const proc = Bun.spawn(
60 ["tar", "-czf", tmpPath, "-C", dir, "."],
61 { stdout: "pipe", stderr: "pipe" }
62 );
63 const exit = await proc.exited;
64 if (exit !== 0) {
65 const err = await new Response(proc.stderr).text().catch(() => "");
66 throw new Error(`tar failed (exit ${exit}): ${err.slice(0, 200)}`);
67 }
68 const bytes = await Bun.file(tmpPath).arrayBuffer();
69 return Buffer.from(bytes);
70 } finally {
71 try {
72 const fs = await import("fs/promises");
a28cedeClaude73 await fs.unlink(tmpPath).catch((err) => {
74 console.warn(
75 `[upload-artifact] tmpPath cleanup failed for ${tmpPath}:`,
76 err instanceof Error ? err.message : err
77 );
78 });
abfa9adClaude79 } catch {
80 /* noop */
81 }
82 }
83}
84
85export const uploadArtifactAction: ActionHandler = {
86 name: "gluecron/upload-artifact",
87 version: "v1",
88 async run(ctx) {
89 try {
90 const parsed = parseInputs(ctx);
91 if ("error" in parsed) {
92 return { exitCode: 1, stderr: parsed.error };
93 }
94
95 // Dynamic import so a missing helper module degrades gracefully
96 // rather than crashing the registry at load time.
97 let uploadArtifact: typeof import("../workflow-artifacts").uploadArtifact;
98 try {
99 ({ uploadArtifact } = await import("../workflow-artifacts"));
100 } catch (err) {
101 return {
102 exitCode: 0,
103 stderr:
104 "upload-artifact unavailable; skipping (" +
105 (err instanceof Error ? err.message : String(err)) +
106 ")",
107 };
108 }
109
110 const abs = join(ctx.workspace, parsed.path);
111 let info;
112 try {
113 info = await stat(abs);
114 } catch (err) {
115 return {
116 exitCode: 1,
117 stderr:
118 `upload-artifact: path not found: ${parsed.path} (${err instanceof Error ? err.message : String(err)})`,
119 };
120 }
121
122 let content: Buffer;
123 let contentType: string;
124 if (info.isDirectory()) {
125 content = await tarGzDirectory(abs);
126 contentType = "application/gzip";
127 } else if (info.isFile()) {
128 const bytes = await Bun.file(abs).arrayBuffer();
129 content = Buffer.from(bytes);
130 contentType = guessContentType(basename(abs));
131 } else {
132 return {
133 exitCode: 1,
134 stderr: `upload-artifact: unsupported path type for ${parsed.path}`,
135 };
136 }
137
138 const result = await uploadArtifact({
139 runId: ctx.runId,
140 jobId: ctx.jobId,
141 name: parsed.name,
142 content,
143 contentType,
144 });
145
146 if (!result.ok) {
147 return {
148 exitCode: 1,
149 stderr: `upload-artifact: ${result.error}`,
150 };
151 }
152
153 return {
154 exitCode: 0,
155 stdout: `Uploaded artifact "${parsed.name}" (${content.byteLength} bytes, ${contentType})`,
156 outputs: {
157 "artifact-id": result.artifactId,
158 name: parsed.name,
159 size: String(content.byteLength),
160 },
161 };
162 } catch (err) {
163 return {
164 exitCode: 1,
165 stderr:
166 "upload-artifact error: " +
167 (err instanceof Error ? err.message : String(err)),
168 };
169 }
170 },
171};