CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
download-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.
| abfa9ad | 1 | /** |
| 2 | * `gluecron/download-artifact@v1` — restores a previously uploaded artifact | |
| 3 | * into the workspace. Inverse of `upload-artifact@v1`. | |
| 4 | * | |
| 5 | * `with:` inputs: | |
| 6 | * name: string (required) — artifact name to fetch | |
| 7 | * path?: string (optional) — destination dir relative to workspace; | |
| 8 | * defaults to '.' | |
| 9 | * optional?: boolean (optional) — when true, a missing artifact returns | |
| 10 | * exitCode 0 (otherwise 1) | |
| 11 | * | |
| 12 | * If the stored artifact is a tar-gz (content-type `application/gzip` or | |
| 13 | * `application/x-tar`), it's extracted into the destination directory. Any | |
| 14 | * other content type is written as-is to `<dest>/<name>`. | |
| 15 | * | |
| 16 | * Like its sibling, gracefully degrades when the helper module can't be | |
| 17 | * imported (exitCode 0 with stderr note). | |
| 18 | */ | |
| 19 | ||
| 20 | import { mkdir } from "fs/promises"; | |
| 21 | import { dirname, join } from "path"; | |
| 22 | import { tmpdir } from "os"; | |
| 23 | import type { ActionHandler, ActionContext } from "../action-registry"; | |
| 24 | ||
| 25 | function parseInputs( | |
| 26 | ctx: ActionContext | |
| 27 | ): { name: string; path: string; optional: boolean } | { error: string } { | |
| 28 | const w = ctx.with || {}; | |
| 29 | const name = typeof w.name === "string" ? w.name.trim() : ""; | |
| 30 | const pathRaw = typeof w.path === "string" ? w.path.trim() : ""; | |
| 31 | const path = pathRaw || "."; | |
| 32 | const optional = w.optional === true || w.optional === "true"; | |
| 33 | if (!name) return { error: "download-artifact: `name` is required" }; | |
| 34 | return { name, path, optional }; | |
| 35 | } | |
| 36 | ||
| 37 | function isArchive(contentType: string): boolean { | |
| 38 | const ct = (contentType || "").toLowerCase(); | |
| 39 | return ( | |
| 40 | ct === "application/gzip" || | |
| 41 | ct === "application/x-gzip" || | |
| 42 | ct === "application/x-tar" || | |
| 43 | ct === "application/tar+gzip" | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | async function extractArchive(content: Buffer, destDir: string): Promise<void> { | |
| 48 | await mkdir(destDir, { recursive: true }); | |
| 49 | const tmpPath = join( | |
| 50 | tmpdir(), | |
| 2c3ba6e | 51 | `gluecron-dl-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar.gz` |
| abfa9ad | 52 | ); |
| 53 | await Bun.write(tmpPath, content); | |
| 54 | try { | |
| 55 | const proc = Bun.spawn( | |
| 56 | ["tar", "-xf", tmpPath, "-C", destDir], | |
| 57 | { stdout: "pipe", stderr: "pipe" } | |
| 58 | ); | |
| 59 | const exit = await proc.exited; | |
| 60 | if (exit !== 0) { | |
| 61 | const err = await new Response(proc.stderr).text().catch(() => ""); | |
| 62 | throw new Error(`tar extract failed (exit ${exit}): ${err.slice(0, 200)}`); | |
| 63 | } | |
| 64 | } finally { | |
| 65 | try { | |
| 66 | const fs = await import("fs/promises"); | |
| a28cede | 67 | await fs.unlink(tmpPath).catch((err) => { |
| 68 | console.warn( | |
| 69 | `[download-artifact] tmpPath cleanup failed for ${tmpPath}:`, | |
| 70 | err instanceof Error ? err.message : err | |
| 71 | ); | |
| 72 | }); | |
| abfa9ad | 73 | } catch { |
| 74 | /* noop */ | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | export const downloadArtifactAction: ActionHandler = { | |
| 80 | name: "gluecron/download-artifact", | |
| 81 | version: "v1", | |
| 82 | async run(ctx): Promise<import("../action-registry").ActionResult> { | |
| 83 | try { | |
| 84 | const parsed = parseInputs(ctx); | |
| 85 | if ("error" in parsed) { | |
| 86 | return { exitCode: 1, stderr: parsed.error }; | |
| 87 | } | |
| 88 | ||
| 89 | // Dynamic import so a missing helper module degrades gracefully. | |
| 90 | let listArtifacts: typeof import("../workflow-artifacts").listArtifacts; | |
| 91 | let downloadArtifact: typeof import("../workflow-artifacts").downloadArtifact; | |
| 92 | try { | |
| 93 | const mod = await import("../workflow-artifacts"); | |
| 94 | listArtifacts = mod.listArtifacts; | |
| 95 | downloadArtifact = mod.downloadArtifact; | |
| 96 | } catch (err) { | |
| 97 | return { | |
| 98 | exitCode: 0, | |
| 99 | stderr: | |
| 100 | "download-artifact unavailable; skipping (" + | |
| 101 | (err instanceof Error ? err.message : String(err)) + | |
| 102 | ")", | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 106 | const listed = await listArtifacts(ctx.runId); | |
| 107 | if (!listed.ok) { | |
| 108 | return { | |
| 109 | exitCode: parsed.optional ? 0 : 1, | |
| 110 | stderr: `download-artifact: list failed: ${listed.error}`, | |
| 111 | }; | |
| 112 | } | |
| 113 | ||
| 114 | const match = listed.artifacts.find((a) => a.name === parsed.name); | |
| 115 | if (!match) { | |
| 116 | const msg = `download-artifact: no artifact named "${parsed.name}" on run ${ctx.runId}`; | |
| 117 | return { | |
| 118 | exitCode: parsed.optional ? 0 : 1, | |
| 119 | stderr: msg, | |
| 120 | outputs: { found: "false" }, | |
| 121 | }; | |
| 122 | } | |
| 123 | ||
| 124 | const fetched = await downloadArtifact(match.id); | |
| 125 | if (!fetched.ok) { | |
| 126 | return { | |
| 127 | exitCode: parsed.optional ? 0 : 1, | |
| 128 | stderr: `download-artifact: fetch failed: ${fetched.error}`, | |
| 129 | }; | |
| 130 | } | |
| 131 | ||
| 132 | const destDir = join(ctx.workspace, parsed.path); | |
| 133 | if (isArchive(fetched.contentType)) { | |
| 134 | await extractArchive(fetched.content, destDir); | |
| 135 | } else { | |
| 136 | const outPath = join(destDir, parsed.name); | |
| 137 | await mkdir(dirname(outPath), { recursive: true }); | |
| 138 | await Bun.write(outPath, fetched.content); | |
| 139 | } | |
| 140 | ||
| 141 | return { | |
| 142 | exitCode: 0, | |
| 143 | stdout: `Downloaded artifact "${parsed.name}" (${fetched.content.byteLength} bytes) to ${parsed.path}`, | |
| 144 | outputs: { | |
| 145 | found: "true", | |
| 146 | "artifact-id": match.id, | |
| 147 | name: parsed.name, | |
| 148 | size: String(fetched.content.byteLength), | |
| 149 | }, | |
| 150 | }; | |
| 151 | } catch (err) { | |
| 152 | return { | |
| 153 | exitCode: 1, | |
| 154 | stderr: | |
| 155 | "download-artifact error: " + | |
| 156 | (err instanceof Error ? err.message : String(err)), | |
| 157 | }; | |
| 158 | } | |
| 159 | }, | |
| 160 | }; |