Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
download-artifact-action.ts5.1 KB · 155 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
/**
 * `gluecron/download-artifact@v1` — restores a previously uploaded artifact
 * into the workspace. Inverse of `upload-artifact@v1`.
 *
 * `with:` inputs:
 *   name: string         (required) — artifact name to fetch
 *   path?: string        (optional) — destination dir relative to workspace;
 *                                     defaults to '.'
 *   optional?: boolean   (optional) — when true, a missing artifact returns
 *                                     exitCode 0 (otherwise 1)
 *
 * If the stored artifact is a tar-gz (content-type `application/gzip` or
 * `application/x-tar`), it's extracted into the destination directory. Any
 * other content type is written as-is to `<dest>/<name>`.
 *
 * Like its sibling, gracefully degrades when the helper module can't be
 * imported (exitCode 0 with stderr note).
 */

import { mkdir } from "fs/promises";
import { dirname, join } from "path";
import { tmpdir } from "os";
import type { ActionHandler, ActionContext } from "../action-registry";

function parseInputs(
  ctx: ActionContext
): { name: string; path: string; optional: boolean } | { error: string } {
  const w = ctx.with || {};
  const name = typeof w.name === "string" ? w.name.trim() : "";
  const pathRaw = typeof w.path === "string" ? w.path.trim() : "";
  const path = pathRaw || ".";
  const optional = w.optional === true || w.optional === "true";
  if (!name) return { error: "download-artifact: `name` is required" };
  return { name, path, optional };
}

function isArchive(contentType: string): boolean {
  const ct = (contentType || "").toLowerCase();
  return (
    ct === "application/gzip" ||
    ct === "application/x-gzip" ||
    ct === "application/x-tar" ||
    ct === "application/tar+gzip"
  );
}

async function extractArchive(content: Buffer, destDir: string): Promise<void> {
  await mkdir(destDir, { recursive: true });
  const tmpPath = join(
    tmpdir(),
    `gluecron-dl-${Date.now()}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}.tar.gz`
  );
  await Bun.write(tmpPath, content);
  try {
    const proc = Bun.spawn(
      ["tar", "-xf", tmpPath, "-C", destDir],
      { stdout: "pipe", stderr: "pipe" }
    );
    const exit = await proc.exited;
    if (exit !== 0) {
      const err = await new Response(proc.stderr).text().catch(() => "");
      throw new Error(`tar extract failed (exit ${exit}): ${err.slice(0, 200)}`);
    }
  } finally {
    try {
      const fs = await import("fs/promises");
      await fs.unlink(tmpPath).catch(() => {});
    } catch {
      /* noop */
    }
  }
}

export const downloadArtifactAction: ActionHandler = {
  name: "gluecron/download-artifact",
  version: "v1",
  async run(ctx): Promise<import("../action-registry").ActionResult> {
    try {
      const parsed = parseInputs(ctx);
      if ("error" in parsed) {
        return { exitCode: 1, stderr: parsed.error };
      }

      // Dynamic import so a missing helper module degrades gracefully.
      let listArtifacts: typeof import("../workflow-artifacts").listArtifacts;
      let downloadArtifact: typeof import("../workflow-artifacts").downloadArtifact;
      try {
        const mod = await import("../workflow-artifacts");
        listArtifacts = mod.listArtifacts;
        downloadArtifact = mod.downloadArtifact;
      } catch (err) {
        return {
          exitCode: 0,
          stderr:
            "download-artifact unavailable; skipping (" +
            (err instanceof Error ? err.message : String(err)) +
            ")",
        };
      }

      const listed = await listArtifacts(ctx.runId);
      if (!listed.ok) {
        return {
          exitCode: parsed.optional ? 0 : 1,
          stderr: `download-artifact: list failed: ${listed.error}`,
        };
      }

      const match = listed.artifacts.find((a) => a.name === parsed.name);
      if (!match) {
        const msg = `download-artifact: no artifact named "${parsed.name}" on run ${ctx.runId}`;
        return {
          exitCode: parsed.optional ? 0 : 1,
          stderr: msg,
          outputs: { found: "false" },
        };
      }

      const fetched = await downloadArtifact(match.id);
      if (!fetched.ok) {
        return {
          exitCode: parsed.optional ? 0 : 1,
          stderr: `download-artifact: fetch failed: ${fetched.error}`,
        };
      }

      const destDir = join(ctx.workspace, parsed.path);
      if (isArchive(fetched.contentType)) {
        await extractArchive(fetched.content, destDir);
      } else {
        const outPath = join(destDir, parsed.name);
        await mkdir(dirname(outPath), { recursive: true });
        await Bun.write(outPath, fetched.content);
      }

      return {
        exitCode: 0,
        stdout: `Downloaded artifact "${parsed.name}" (${fetched.content.byteLength} bytes) to ${parsed.path}`,
        outputs: {
          found: "true",
          "artifact-id": match.id,
          name: parsed.name,
          size: String(fetched.content.byteLength),
        },
      };
    } catch (err) {
      return {
        exitCode: 1,
        stderr:
          "download-artifact error: " +
          (err instanceof Error ? err.message : String(err)),
      };
    }
  },
};