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.tsBlame166 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");
73 await fs.unlink(tmpPath).catch(() => {});
74 } catch {
75 /* noop */
76 }
77 }
78}
79
80export const uploadArtifactAction: ActionHandler = {
81 name: "gluecron/upload-artifact",
82 version: "v1",
83 async run(ctx) {
84 try {
85 const parsed = parseInputs(ctx);
86 if ("error" in parsed) {
87 return { exitCode: 1, stderr: parsed.error };
88 }
89
90 // Dynamic import so a missing helper module degrades gracefully
91 // rather than crashing the registry at load time.
92 let uploadArtifact: typeof import("../workflow-artifacts").uploadArtifact;
93 try {
94 ({ uploadArtifact } = await import("../workflow-artifacts"));
95 } catch (err) {
96 return {
97 exitCode: 0,
98 stderr:
99 "upload-artifact unavailable; skipping (" +
100 (err instanceof Error ? err.message : String(err)) +
101 ")",
102 };
103 }
104
105 const abs = join(ctx.workspace, parsed.path);
106 let info;
107 try {
108 info = await stat(abs);
109 } catch (err) {
110 return {
111 exitCode: 1,
112 stderr:
113 `upload-artifact: path not found: ${parsed.path} (${err instanceof Error ? err.message : String(err)})`,
114 };
115 }
116
117 let content: Buffer;
118 let contentType: string;
119 if (info.isDirectory()) {
120 content = await tarGzDirectory(abs);
121 contentType = "application/gzip";
122 } else if (info.isFile()) {
123 const bytes = await Bun.file(abs).arrayBuffer();
124 content = Buffer.from(bytes);
125 contentType = guessContentType(basename(abs));
126 } else {
127 return {
128 exitCode: 1,
129 stderr: `upload-artifact: unsupported path type for ${parsed.path}`,
130 };
131 }
132
133 const result = await uploadArtifact({
134 runId: ctx.runId,
135 jobId: ctx.jobId,
136 name: parsed.name,
137 content,
138 contentType,
139 });
140
141 if (!result.ok) {
142 return {
143 exitCode: 1,
144 stderr: `upload-artifact: ${result.error}`,
145 };
146 }
147
148 return {
149 exitCode: 0,
150 stdout: `Uploaded artifact "${parsed.name}" (${content.byteLength} bytes, ${contentType})`,
151 outputs: {
152 "artifact-id": result.artifactId,
153 name: parsed.name,
154 size: String(content.byteLength),
155 },
156 };
157 } catch (err) {
158 return {
159 exitCode: 1,
160 stderr:
161 "upload-artifact error: " +
162 (err instanceof Error ? err.message : String(err)),
163 };
164 }
165 },
166};