Skip to content

Commit 86c517f

Browse files
Add docker support to distribute sub-action (#62)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a6dc88a commit 86c517f

10 files changed

Lines changed: 293 additions & 16 deletions

File tree

README.md

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ Use `[LATEST]` for "give me whatever the most recent build is" workflows (consum
191191

192192
### Distribute
193193

194-
Make a generic artifact version publicly downloadable — anyone with the link can grab it without a Fly account. Idempotent: calling again returns the same public URL.
194+
Make an artifact version publicly downloadable — anyone with the link can grab it without a Fly account. Idempotent: calling again returns the same public URL. Supported package types: **generic**, **docker**.
195+
196+
#### Generic
195197

196198
```yaml
197199
- name: Distribute the artifact
@@ -201,13 +203,7 @@ Make a generic artifact version publicly downloadable — anyone with the link c
201203
version: '1.0.0'
202204
```
203205

204-
| Input | Description | Required | Default |
205-
| --- | --- | --- | --- |
206-
| `name` | Package name | Yes | |
207-
| `version` | Package version to make public (concrete; `[LATEST]` not supported here) | Yes | |
208-
| `type` | Artifact type. Currently only `generic` is supported. | No | `generic` |
209-
210-
After distribute succeeds, consumers can fetch the file anonymously:
206+
After distribute succeeds, consumers fetch the file anonymously:
211207

212208
```bash
213209
# Specific version
@@ -217,9 +213,50 @@ curl -O https://{tenant}.jfrog.io/public/generic/my-app/1.0.0/app.zip
217213
curl -LO https://{tenant}.jfrog.io/public/generic/my-app/[LATEST]/app.zip
218214
```
219215

220-
The public URL pattern is `https://{tenant}.jfrog.io/public/generic/{name}/{version}/{file}`. On the public path `[LATEST]` is resolved server-side via `302` + `Cache-Control: no-store`, so consumers always see the latest published version (CDN never serves a stale resolution). The authenticated `download` sub-action also resolves `[LATEST]` — via inline proxy (no redirect) — see [`[LATEST]` resolution](#latest-resolution) above.
216+
The generic public URL pattern is `https://{tenant}.jfrog.io/public/generic/{name}/{version}/{file}`.
217+
218+
#### Docker
219+
220+
The image must already have been pushed to the tenant's `docker-local` repo (via `docker push {tenant}.jfrog.io/docker/...` after `jfrog/fly-action` configured the registry). The distribute step copies the manifest and layer blobs into the `{tenant}-docker-public` repo so anonymous pulls can resolve them.
221+
222+
```yaml
223+
- name: Distribute the docker image
224+
uses: jfrog/fly-action/distribute@v1
225+
with:
226+
name: myorg/my-image # image name without registry host or `docker-public/` prefix
227+
version: '1.0.0' # image tag
228+
type: docker
229+
```
230+
231+
After distribute succeeds, consumers pull the image anonymously:
232+
233+
```bash
234+
# Specific tag
235+
docker pull {tenant}.jfrog.io/docker-public/myorg/my-image:1.0.0
236+
237+
# Latest tag (server 302-redirects to the newest distributed tag)
238+
docker pull {tenant}.jfrog.io/docker-public/myorg/my-image:[LATEST]
239+
```
240+
241+
The docker public URL pattern is `https://{tenant}.jfrog.io/v2/docker-public/{image}/manifests/{tag}` (OCI registry API). The `/v2/docker-public/*` namespace is read-only — pushes always go through the authenticated distribute step above, never anonymously.
242+
243+
#### Distribute inputs
244+
245+
| Input | Description | Required | Default |
246+
| --- | --- | --- | --- |
247+
| `name` | Package name (for docker, the image name without the registry host or `docker-public/` prefix; nested repos like `myorg/myimg` are supported) | Yes | |
248+
| `version` | Package version to make public (for docker, the image tag). Concrete only — `[LATEST]` is not accepted here. | Yes | |
249+
| `type` | Artifact type. Supported values: `generic`, `docker`. | No | `generic` |
250+
251+
#### `[LATEST]` on the public path
252+
253+
`[LATEST]` is resolved server-side on **both** public URL shapes (generic `/public/generic/...` and docker `/v2/docker-public/...`) via `302 Found` + `Cache-Control: no-store`, so consumers always see the latest distributed version and the CDN never pins a stale resolution. The authenticated `download` sub-action also resolves `[LATEST]` — via inline proxy, no redirect — see [`[LATEST]` resolution](#latest-resolution) above.
254+
255+
`[LATEST]` is **download-only**. The Fly server rejects it with `400 Bad Request` on `distribute` (and `upload`) so a literal `[LATEST]`-named folder or tag can never poison future resolution.
256+
257+
#### Distribute outputs
221258

222-
The `results` output is a JSON array with one entry: `{package_name, package_version, package_type, public_url, download_url, download_count}`. Multiple distribute steps in one job accumulate in the job summary's _Distributed Artifacts_ table.
259+
The `results` output is a JSON array with one entry: `{package_name, package_version, package_type, public_url, download_url, download_count, files?}`. Docker distributions also surface a `Pull:` line in the step logs with the ready-to-paste `docker pull …` command. Multiple distribute steps in one job accumulate in the job summary's _Distributed Artifacts_ table.
223260

224261
### Go Publish
225262

distribute/action.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
name: 'Fly Distribute'
2-
description: 'Make a generic artifact version publicly downloadable. After distribute, anyone can fetch the file at https://{tenant}.jfrog.io/public/generic/{name}/{version}/{file} (no auth). Use [LATEST] in the public URL to always get the newest distributed version. Requires jfrog/fly-action to have run first for authentication.'
2+
description: 'Make an artifact version publicly downloadable. After distribute, consumers can fetch generic artifacts at https://{tenant}.jfrog.io/public/generic/{name}/{version}/{file} (no auth) or pull docker images with `docker pull {tenant}.jfrog.io/docker-public/{image}:{tag}` (no auth). Use [LATEST] in either URL shape to always get the newest distributed version. Requires jfrog/fly-action to have run first for authentication.'
33
author: 'JFrog'
44

55
inputs:
66
name:
7-
description: 'Artifact name'
7+
description: 'Artifact name (for docker, the image name without the registry host or `docker-public/` prefix; nested repos like `myorg/myimg` are supported)'
88
required: true
99
version:
10-
description: 'Artifact version'
10+
description: 'Artifact version (for docker, the image tag)'
1111
required: true
1212
type:
13-
description: 'Artifact type. Currently only "generic" is supported.'
13+
description: 'Artifact type. Supported values: `generic`, `docker`.'
1414
required: false
1515
default: 'generic'
1616

lib/distribute.js

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/post.js

Lines changed: 26 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/distribute-core.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,46 @@ export async function distributeArtifact(
6161
);
6262
core.info(` Public URL: ${parsed.public_url}`);
6363
core.info(` Download: ${parsed.download_url}`);
64+
const pullCommand = buildDockerPullCommand(parsed);
65+
if (pullCommand) {
66+
core.info(` Pull: ${pullCommand}`);
67+
}
6468

6569
return parsed;
6670
} finally {
6771
httpClient.dispose();
6872
}
6973
}
74+
75+
/**
76+
* Returns the `docker pull …` reference for a Docker distribution, or `null`
77+
* for non-Docker types or when `public_url` doesn't match the expected
78+
* `/v2/{repo}/{image}` shape. Surfacing the pull command is the most useful
79+
* line for a Docker consumer — `public_url` alone is the OCI namespace, not a
80+
* command they can paste, and `download_url` resolves to the JSON manifest.
81+
*
82+
* Exported so the job-summary renderer can reuse the same derivation and
83+
* stay consistent with the step log line (no drift between sources).
84+
*/
85+
export function buildDockerPullCommand(
86+
response: DistributeResponse,
87+
): string | null {
88+
if (response.package_type !== "docker") {
89+
return null;
90+
}
91+
let parsedUrl: URL;
92+
try {
93+
parsedUrl = new URL(response.public_url);
94+
} catch {
95+
return null;
96+
}
97+
// public_url is `/v2/{repo}/{image}` per the Fly OCI public namespace
98+
// contract. Strip the OCI `/v2/` prefix so the result is the docker
99+
// reference `{host}/{repo}/{image}:{tag}`. If the prefix is missing the
100+
// backend changed shape — skip rather than emit a misleading command.
101+
const pathWithoutV2 = parsedUrl.pathname.replace(/^\/v2\//, "/");
102+
if (pathWithoutV2 === parsedUrl.pathname) {
103+
return null;
104+
}
105+
return `docker pull ${parsedUrl.host}${pathWithoutV2}:${response.package_version}`;
106+
}

src/distribute.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,60 @@ describe("runDistribute", () => {
130130
expect(body.package_type).toBe("generic");
131131
});
132132

133+
it("distributes a docker image and logs the pull command", async () => {
134+
mockInputs({ name: "myorg/my-image", version: "1.0.0", type: "docker" });
135+
136+
const dockerResponse: DistributeResponse = {
137+
package_name: "myorg/my-image",
138+
package_version: "1.0.0",
139+
package_type: "docker",
140+
public_url: "https://flyjfrog.jfrog.io/v2/docker-public/myorg/my-image",
141+
download_url:
142+
"https://flyjfrog.jfrog.io/v2/docker-public/myorg/my-image/manifests/1.0.0",
143+
download_count: 0,
144+
files: [
145+
{
146+
package_name: "myorg/my-image",
147+
package_version: "1.0.0",
148+
file_name: "manifest.json",
149+
sha256: "abcd",
150+
download_count: 0,
151+
},
152+
],
153+
};
154+
155+
mockPost.mockResolvedValue({
156+
message: { statusCode: 200 },
157+
readBody: () => Promise.resolve(JSON.stringify(dockerResponse)),
158+
});
159+
160+
await runDistribute();
161+
162+
expect(core.setFailed).not.toHaveBeenCalled();
163+
// Payload carries package_type=docker through to the backend unchanged.
164+
const body = JSON.parse(mockPost.mock.calls[0][1]);
165+
expect(body.package_type).toBe("docker");
166+
// Pull command is derived from public_url + version, with `/v2/` stripped
167+
// so consumers can paste it into a shell.
168+
expect(core.info).toHaveBeenCalledWith(
169+
" Pull: docker pull flyjfrog.jfrog.io/docker-public/myorg/my-image:1.0.0",
170+
);
171+
});
172+
173+
it("does not log a pull command for non-docker distributions", async () => {
174+
mockInputs({ name: "my-app", version: "1.0.0", type: "generic" });
175+
176+
mockPost.mockResolvedValue({
177+
message: { statusCode: 200 },
178+
readBody: () => Promise.resolve(JSON.stringify(MOCK_RESPONSE)),
179+
});
180+
181+
await runDistribute();
182+
183+
const infoCalls = vi.mocked(core.info).mock.calls.map((c) => c[0]);
184+
expect(infoCalls.some((line) => line.includes("Pull:"))).toBe(false);
185+
});
186+
133187
it("accumulates results across multiple runDistribute invocations", async () => {
134188
const response2: DistributeResponse = {
135189
...MOCK_RESPONSE,

src/distribute.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export async function runDistribute(): Promise<void> {
1717
try {
1818
const name = core.getInput(INPUT_NAME, { required: true });
1919
const version = core.getInput(INPUT_VERSION, { required: true });
20+
// NOTE: `type` is forwarded to fly-service without a client-side allowlist
21+
// check on purpose — the backend is the single source of truth for which
22+
// package types are publicly distributable and returns a typed 400 for
23+
// unsupported values. Keeping the allowlist server-side prevents the
24+
// action and fly-service from drifting (e.g. when Phase 3 wires helm).
2025
const packageType = core.getInput(INPUT_DISTRIBUTE_TYPE) || "generic";
2126

2227
const { url, token } = getAuthEnv();

src/job-summary.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,61 @@ describe("buildDistributedTable", () => {
485485
expect(table).toContain("my\\|app");
486486
expect(table).toContain("1\\|0");
487487
});
488+
489+
it("renders docker rows with a `docker pull …` command instead of the manifest URL", () => {
490+
const manifestUrl =
491+
"https://flyjfrog.jfrog.io/v2/docker-public/myorg/my-image/manifests/1.0.0";
492+
const results: DistributeResponse[] = [
493+
{
494+
package_name: "myorg/my-image",
495+
package_version: "1.0.0",
496+
package_type: "docker",
497+
public_url: "https://flyjfrog.jfrog.io/v2/docker-public/myorg/my-image",
498+
download_url: manifestUrl,
499+
download_count: 0,
500+
},
501+
];
502+
503+
const table = buildDistributedTable(results);
504+
// Inline-code pull command derived from public_url + version. Backticks
505+
// signal "command, not URL" in the rendered markdown table.
506+
expect(table).toContain(
507+
"`docker pull flyjfrog.jfrog.io/docker-public/myorg/my-image:1.0.0`",
508+
);
509+
// Manifest URL must not leak into the cell — clicking it returns JSON.
510+
expect(table).not.toContain(`[${manifestUrl}](${manifestUrl})`);
511+
});
512+
513+
it("mixes generic and docker rows: each renders its own cell shape", () => {
514+
const genericDownload =
515+
"https://fly.example.com/public/generic/tenant/my-app/1.0.0/my-app.tar.gz";
516+
const results: DistributeResponse[] = [
517+
{
518+
package_name: "my-app",
519+
package_version: "1.0.0",
520+
package_type: "generic",
521+
public_url:
522+
"https://fly.example.com/public/generic/tenant/my-app/1.0.0",
523+
download_url: genericDownload,
524+
download_count: 0,
525+
},
526+
{
527+
package_name: "myorg/my-image",
528+
package_version: "2.0.0",
529+
package_type: "docker",
530+
public_url: "https://flyjfrog.jfrog.io/v2/docker-public/myorg/my-image",
531+
download_url:
532+
"https://flyjfrog.jfrog.io/v2/docker-public/myorg/my-image/manifests/2.0.0",
533+
download_count: 0,
534+
},
535+
];
536+
537+
const table = buildDistributedTable(results);
538+
expect(table).toContain(`[${genericDownload}](${genericDownload})`);
539+
expect(table).toContain(
540+
"`docker pull flyjfrog.jfrog.io/docker-public/myorg/my-image:2.0.0`",
541+
);
542+
});
488543
});
489544

490545
describe("buildArtifactsTable", () => {

src/job-summary.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "./constants";
1616
import { getErrorMessage } from "./utils";
1717
import { resolveAndDedup, resolveArtifact, dedupKey } from "./artifact-path";
18+
import { buildDockerPullCommand } from "./distribute-core";
1819

1920
const escPipe = (s: string): string => s.replace(/\|/g, "\\|");
2021

@@ -108,11 +109,26 @@ export function buildDistributedTable(results: DistributeResponse[]): string {
108109
const header = "| Package | Version | Download URL |\n| --- | --- | --- |";
109110
const rows = results.map(
110111
(r) =>
111-
`| ${escPipe(r.package_name)} | ${escPipe(r.package_version)} | [${escPipe(r.download_url)}](${r.download_url}) |`,
112+
`| ${escPipe(r.package_name)} | ${escPipe(r.package_version)} | ${renderDistributedDownloadCell(r)} |`,
112113
);
113114
return `\n### 🌐 Distributed Artifacts\n\n${header}\n${rows.join("\n")}\n`;
114115
}
115116

117+
/**
118+
* Builds the third-column cell for a distributed-artifacts row. Generic rows
119+
* show a clickable link to the file. Docker rows show the `docker pull …`
120+
* command in inline code: the raw `download_url` for docker points at the
121+
* OCI manifest JSON, which is useless to skim in a job summary — consumers
122+
* actually need a paste-ready pull command.
123+
*/
124+
function renderDistributedDownloadCell(r: DistributeResponse): string {
125+
const pullCommand = buildDockerPullCommand(r);
126+
if (pullCommand) {
127+
return `\`${escPipe(pullCommand)}\``;
128+
}
129+
return `[${escPipe(r.download_url)}](${r.download_url})`;
130+
}
131+
116132
export function buildTransfersTable(entries: TransferSummaryEntry[]): string {
117133
if (entries.length === 0) return "";
118134

0 commit comments

Comments
 (0)