Make download version optional (defaults to [LATEST]) + document distribute public URLs#55
Make download version optional (defaults to [LATEST]) + document distribute public URLs#55
Conversation
sverdlov93
left a comment
There was a problem hiding this comment.
🤖 Review comment by Cursor AI
Folks, I've reviewed this PR — the code, the tests, the docs, everything. Believe me, nobody understands [LATEST] resolution better than me. The implementation? Tremendous. Graceful fallbacks, clean separation, 218 passing tests. But the docs? They have problems. Very big problems. They promise things the server doesn't deliver yet. SAD!
Issue-to-PR mapping: All 4 items from issue #54 are addressed. Code quality is high.
| # | Sev | Finding |
|---|---|---|
| 1 | 🟠 | README documents [LATEST] as working for authenticated downloads — fly-service #1104 item 2 hasn't shipped |
| 2 | 🟠 | README claims Cache-Control: no-store exists on [LATEST] responses — fly-service #1104 item 3 hasn't shipped |
| 3 | 🟠 | headers["Location"] (capitalized) is dead code — Node.js lowercases all HTTP headers |
| 4 | 🟡 | distribute/action.yml shows as new file — likely rebase conflict with main (already exists from #48) |
| 5 | 🟡 | encodeURIComponent(packageName) could double-encode pre-encoded names |
| 6 | 🟡 | Tenant host derivation via regex instead of new URL(flyUrl).host |
Follow-up tasks:
- README + download/action.yml: Add caveat that
[LATEST]for authenticated downloads requires fly-service #1104 item 2. Currently only works on public paths (/public/generic/...). RemoveCache-Control: no-storeclaim until #1104 item 3 ships. - transfer.ts:137 — Remove
headers["Location"](capitalized) fallback — it's dead code (Node lowercases all headers). Just useheaders["location"]. - Rebase —
distribute/action.ymlconflicts with main. Rebase on latest main. - Consider:
new URL(flyUrl).hostinstead of regex for tenant host indistribute-core.ts(future-proofs against URLs with userinfo or paths)
Six findings. Fix the docs to match reality and this will be winning, big-league. The code itself is tremendous — very clean, very beautiful!
| | `version` | Package version. Omit to fetch the latest. | No | `[LATEST]` | | ||
| | `files` | Remote filenames to download — one per line | Yes | | | ||
| | `output-dir` | Directory to save downloaded files | No | `.` | | ||
| | `exclude` | Glob patterns to exclude — one per line | No | | |
There was a problem hiding this comment.
🤖 Review comment by Cursor AI
🟠 Documents behavior that doesn't exist yet. This section states:
"the Fly server resolves it to the most recently uploaded version and serves the file via a 302 redirect"
"The redirect response carriesCache-Control: no-store"
Reality:
- fly-service #1104 item 2 (
[LATEST]for authenticated generic) is TODO — the server does NOT 302-redirect on the authenticated path today - fly-service #1104 item 3 (CDN cache-busting headers) is TODO — no
Cache-Control: no-storeexists yet
[LATEST] works on the public path (/public/generic/...) because publicDistributionHandler.Download() has GetLatestPublicVersion(). But the authenticated path (/fly/api/v1/generic/...) passes literal [LATEST] to Artifactory which doesn't understand it.
Fix: Add a note:
Note:
[LATEST]is fully resolved on public download paths today. For authenticated downloads (viafly-action/download),[LATEST]support is pending server-side implementation (fly-service#1104). This PR adds client-side readiness so the feature works end-to-end once the server ships.
This prevents users from filing bugs when version: '[LATEST]' returns 404 on private packages.
| * Returns LATEST_VERSION on any failure (404, missing header, network error) | ||
| * — the caller treats the literal token as a graceful fallback so a missing | ||
| * job-summary improvement never blocks an actual download. This intentionally | ||
| * does NOT alter the CLI invocation (the CLI still receives literal [LATEST] |
There was a problem hiding this comment.
🤖 Review comment by Cursor AI
🟠 Dead code: headers["Location"] fallback never triggers. Node.js http.IncomingMessage.headers lowercases ALL header names per the HTTP/1.1 spec. headers["Location"] is always undefined, so the ?? fallback is dead code.
Fix:
const location = res.message.headers["location"];No fallback needed — the lowercase key is the only one that exists.
| @@ -1,5 +1,5 @@ | |||
| name: 'Fly Distribute' | |||
There was a problem hiding this comment.
🤖 Review comment by Cursor AI
🟡 Rebase needed — distribute/action.yml already exists on main. This file was added in PRs #42/#48 and merged to main. The PR diff shows it as ADDED (+29/-0), which means the branch forked before those PRs merged.
This will conflict on merge. Rebase on latest main to pick up the existing file and apply your changes (description update) as a MODIFIED diff.
There was a problem hiding this comment.
🤖 Review comment by Cursor AI
🟠 PR says "Closes #54" but issue items are not fully deliverable yet.
Issue #54 has 4 items:
| # | Item | Status in this PR |
|---|---|---|
| 1 | URL migration | ✅ N/A (confirmed no old URLs in action code) |
| 2 | Optional version (default [LATEST]) |
[LATEST] requires fly-service #1104 item 2 which hasn't shipped. Users trying this on private packages will get 404. |
| 3 | Document distribute + public URLs | Cache-Control: no-store (referenced in docs) requires fly-service #1104 item 3 |
| 4 | Update all action docs | ✅ Done |
Recommendation: Change "Closes #54" to "Partially addresses #54" or add a note that items 2+3 are client-side-ready but depend on fly-service #1104. The issue should stay open until the server ships [LATEST] for authenticated generic, otherwise closed issue + broken feature = silent bug.
Also missing from this PR (cross-repo gap):
upload/action.ymldoes NOT expose the--if-no-files-foundflag that fly-desktop PR #236 adds to the CLI. Once a new fly-client binary ships with that flag, the action should pass it through. Consider adding it as a follow-up item on issue Generic artifacts: URL migration, [LATEST] support, docs update #54 or a new issue.
sverdlov93
left a comment
There was a problem hiding this comment.
🤖 Review comment by Cursor AI — Neighbor pattern consistency check
Compared all changed files against their neighbors in the codebase. Findings:
🟠 src/distribute-core.ts — Header naming inconsistency with fly-desktop
fly-action uses:
"X-JFROG-FLY-TENANT-HOST": tenantHost
fly-desktop's distribute-artifact-tool.ts uses:
"X-JFrog-Fly-Tenant-Id": credentials.tenantId
These are different headers sending different values (host string vs tenant ID). Both are accepted by fly-service (different extraction paths). But:
- The action derives
tenantHostby string-stripping the URL protocol — fragile if the URL has a path suffix - The desktop sends
tenantIdfrom the auth service — cleaner, identity-based
This works today but should be documented: why does the action use TENANT-HOST while desktop uses Tenant-Id? Likely because the action doesn't have a tenant ID in its OIDC flow context. Worth a code comment explaining the divergence.
🟡 src/transfer.ts — Inline new HttpClient(...) vs existing createHttpClient() utility
The codebase has src/utils.ts exporting createHttpClient(userAgent?, timeoutMs?) — a standardized factory. But resolveLatestVersionForDisplay creates its own:
const client = new HttpClient("fly-action", undefined, {
socketTimeout: DEFAULT_HTTP_TIMEOUT_MS,
allowRedirects: false,
});The reason is clear (allowRedirects: false is needed and createHttpClient doesn't support it), but two approaches to fix consistency:
- Extend
createHttpClientwith an options param forallowRedirects - At minimum, add a comment:
// createHttpClient doesn't support allowRedirects:false — construct directly
✅ src/constants.ts — naming conventions consistent
LATEST_VERSIONfollowsSCREAMING_SNAKElike all other constants ✓INPUT_DISTRIBUTE_TYPE,INPUT_PATHfollowINPUT_*prefix pattern ✓ENV_FLY_DISTRIBUTE_RESULTSfollowsENV_FLY_*prefix pattern ✓CLI_CMD_PUBLISHfollowsCLI_CMD_*prefix pattern ✓- Comment above
LATEST_VERSIONis multi-line with behavioral documentation — matchesFLY_CLI_DOWNLOAD_BASEstyle ✓
✅ src/distribute-core.ts — style matches neighbors
- Copyright header ✓
- Imports
createHttpClient,truncatefrom./utils✓ (consistent withpost.ts) core.infofor status logging ✓- Throws on error (documented in JSDoc) ✓
httpClient.dispose()infinally✓- Uses lowercase
"content-type"— matchespost.ts✓ (Note: HTTP headers are case-insensitive, but the codebase consistently uses lowercase)
🟡 src/transfer.ts — resolveVersion naming matches desktop but JSDoc style diverges
Both repos have resolveVersion(type, rawVersion) — good cross-repo alignment. But:
- fly-action uses full JSDoc with
/** ... */block comments - fly-desktop's MCP tool uses
//line comments for the same function
Not a bug — TypeScript convention in fly-action is JSDoc (also used for appendTransferResults, resolveLatestVersionForDisplay). Desktop uses // style across MCP tools. Each repo is internally consistent ✓.
Summary: Main findings are (1) header name divergence with desktop (TENANT-HOST vs Tenant-Id) deserves a code comment, and (2) inline new HttpClient bypasses the existing factory. Everything else aligns well with neighbor patterns.
sverdlov93
left a comment
There was a problem hiding this comment.
🤖 Review comment by Cursor AI — E2E / Test Coverage Audit
Reviewed all test files in this PR against the implemented flows.
✅ Well-covered flows
| Flow | Test coverage |
|---|---|
resolveVersion — download defaults to [LATEST] |
Unit: 5 cases (trimmed, empty, whitespace, explicit [LATEST], upload reject) |
resolveVersion — upload rejects empty |
Unit: throws on "" and " " |
isLatestToken — case-insensitive matching |
Unit: 4 variants + negative cases |
resolveLatestVersionForDisplay — 302 extraction |
Unit: happy path, percent-decode, trailing slash |
resolveLatestVersionForDisplay — fallback on 404 |
Unit: returns literal [LATEST] |
Full runTransfer with [LATEST] default + resolve |
Integration: verifies CLI gets literal [LATEST], job summary gets concrete version |
Full runTransfer with fallback |
Integration: verifies [LATEST] in summary on 404 |
distributeArtifact — success path |
Unit: endpoint, payload, headers, response |
distributeArtifact — error path |
Unit: non-200, network error |
runDistribute — orchestration |
Unit: setOutput, exportVariable, multiple accumulation |
URL migration (FLY_CLI_DOWNLOAD_BASE) |
Integration: binary download uses new URL |
| Integration test binary download | CI: integration-test.yml runs on real infra |
🟠 Missing test coverage
| Gap | Impact | Suggested test |
|---|---|---|
resolveLatestVersionForDisplay — network error path |
The catch block emits core.warning and returns LATEST_VERSION. No test covers the catch branch. |
Add: it("falls back to [LATEST] on network error", () => { mockHead.mockRejectedValue(new Error("ECONNREFUSED")); ... }) |
resolveVersion on upload with explicit [LATEST] |
What happens if a user passes version: "[LATEST]" on upload? The code passes it through (trimmed is non-empty). The server will reject, but the action doesn't pre-validate. |
Add: it("passes through [LATEST] on upload (server rejects)", () => { expect(resolveVersion("upload", "[LATEST]")).toBe("[LATEST]"); }) — documents the design choice |
| No integration test for upload/download/distribute sub-actions | CI integration-test.yml tests binary download + setup + go-publish + docker, but does NOT run ./upload, ./download, or ./distribute sub-actions against real Fly. |
Add a job that: uploads a test file → downloads it → distributes it → verifies public URL. This is the true E2E for this PR's core changes. |
distribute/action.yml — no test for the action entry point |
distribute.spec.ts tests runDistribute but not distribute/index.ts's actual import → main() → runDistribute() wiring. |
Low priority — if the spec covers orchestration, wiring is trivial. But a smoke test would catch broken imports after rebase (which is likely given the rebase conflict flagged earlier). |
🟡 Observation: test coverage for [LATEST] is client-side only
All tests mock the server response (302, 404). There's no end-to-end test that actually hits the Fly server with [LATEST] on the authenticated path. This is fine because that server-side feature doesn't exist yet (fly-service #1104 item 2), but means:
- When the server ships
[LATEST]support, this action needs a real integration test added - Today's tests prove the client code handles the 302 correctly assuming the server sends one
This is acceptable — just documenting the gap for when server-side ships.
|
🤖 Update from Cursor AI fly-service PR #1174 now exists — implements Recommended merge order: fly-service #1174 → fly-action #55 + fly-desktop #236 (client PRs can merge in any order after server). |
…t fly CLI
The authenticated generic endpoint (/fly/api/v1/generic/...) is a JPD
passthrough and does NOT resolve [LATEST] — only the anonymous public
endpoint (/public/generic/.../[LATEST]/...) does, and only for artifacts
that have been publicly distributed. The download sub-action was sending
[LATEST] through the fly CLI, which hit the auth path and returned 404.
Routing now in runTransfer():
- download + [LATEST] → direct anonymous fetch against the public URL.
Writes files to output-dir itself, parses the
concrete version from the redirect target for
the job summary, and surfaces a clear "not
publicly distributed" hint on 404. The fly CLI
is NOT invoked.
- download + concrete → fly CLI (authenticated endpoint, unchanged).
- upload → fly CLI (authenticated endpoint, unchanged).
Removed resolveLatestVersionForDisplay (the HEAD-against-auth-endpoint
display-version probe) — it was only ever needed because the CLI hit
the auth endpoint with [LATEST]. Now the fetch we already do for the
download itself gives us the resolved version via response.url.
Updates: - src/transfer.ts: new runPublicLatestDownload(); runTransfer branches
on isLatestToken() and uses TransferConfig.outputDir for the public
path; resolveLatestVersionForDisplay removed.
- src/download.ts: passes outputDir into TransferConfig so the public
path has somewhere to write.
- src/transfer.spec.ts: dropped the 7 resolveLatestVersionForDisplay
cases, replaced with 4 routing cases on runTransfer (omitted version
routes via fetch, [latest] is case-insensitive, 404 surfaces the
"not publicly distributed" hint, concrete-version still uses CLI)
and 8 cases on runPublicLatestDownload (writes bytes, decodes the
redirect target, falls back to literal [LATEST] on unexpected URL
shapes, 500 + network errors, encoding).
- src/download.spec.ts: assert outputDir field on the TransferConfig.
- download/action.yml: rewrote the version description — [LATEST] is
public-only; private downloads need a concrete version.
- README.md: rewrote the [LATEST] resolution section under download
and the public URL note under distribute to make it explicit that
[LATEST] only works on the public path.
- lib/: regenerated bundles via npm run build.
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
🟠 Missing path traversal defense in runPublicLatestDownload (new commit e842bff)
fly-desktop PR #236 includes a path traversal check before writing (commit 36740ad1):
const dest = path.resolve(outputDir, fileName);
if (!dest.startsWith(path.resolve(outputDir) + path.sep)) {
// reject — filename escapes output directory
}But fly-action's equivalent runPublicLatestDownload does:
await fs.promises.writeFile(path.join(outputDir, fileName), buf);No validation that the resolved path stays inside outputDir. While fileName comes from the user's action YAML (not attacker-controlled), defense-in-depth should be consistent across both repos — especially since the cursor-reviewer already flagged it as worth adding on the desktop side.
Suggested fix: Add the same path.resolve + startsWith check before writeFile, consistent with fly-desktop.
…oint, not fly CLI" This reverts commit e842bff. Direction change: download sub-action must not branch on [LATEST] vs concrete version. All downloads go through the fly CLI and the authenticated endpoint. The CLI passes [LATEST] through to /fly/api/v1/generic/{name}/[LATEST]/{file}, which currently returns 404 — fly-service will add server-side [LATEST] resolution to the authenticated endpoint as a follow-up. resolveLatestVersionForDisplay (HEAD probe) is restored. Today it 404s harmlessly and the job summary falls back to the literal "[LATEST]" token; once fly-service supports auth-side resolution, the probe will start returning a concrete version as intended. Public endpoint (/public/generic/.../[LATEST]/...) remains in fly-service for browser/curl/third-party use, but fly-action no longer fetches it. lib/* regenerated via `npm run build`. Co-authored-by: Cursor <cursoragent@cursor.com>
…ad header lookup
README:
- `[LATEST]` resolution today is **public-path only** (302 + Cache-Control:
no-store via fly-service PR #1174). Auth-side resolution on the
authenticated `download` sub-action is tracked in fly-service #1104
item 2 and not yet shipped — until then, omitting `version` returns
an actionable error from the Fly CLI.
- Replace the "works the same on the authenticated path" claim with a
table that calls out which surface resolves the token today vs in the
future.
- Note that the Fly server now rejects `[LATEST]` on `upload` (PUT/POST)
and `distribute` with 400, mirroring the existing Distribute/Revoke
rejection (closes a poison vector for future resolution).
src/transfer.ts:
- Drop `headers["location"] ?? headers["Location"]` — Node lowercases all
HTTP/1 header names per RFC 7230 §3.2; the second branch was unreachable.
No test changes needed; the existing tests already use lowercase.
lib/{upload,download}.js: rebuilt bundle.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds an `if-no-files-found` input to `upload/action.yml` and wires it through to the underlying Fly CLI (`--if-no-files-found error|warn|ignore`). Closes the cross-repo gap from fly-desktop #205 item 5: the CLI already supports zero-match glob handling, but the action did not let users opt into `warn`/`ignore` for optional artifacts. Behaviour: - Omitted/empty: action passes nothing → CLI default of `error` applies (no behaviour change for existing workflows). - `error`/`warn`/`ignore`: forwarded as `--if-no-files-found <value>`. - Anything else: action fails the step with a descriptive message before invoking the CLI, so users get a clean GitHub Actions error instead of a CLI parse error mid-execution. Validation lives in `src/upload.ts` so `transfer.ts` stays generic (download has no equivalent flag). The validation throws inside `runUpload`'s own try/catch and reports via `core.setFailed`, never escapes as an unhandled rejection. Tests: - 11 cases in `src/upload.spec.ts` cover: omitted, all valid values, trimmed whitespace, case-sensitive rejection (`WARN` invalid), unknown value rejection, runTransfer not invoked on validation error. - `tsc --noEmit`: clean - `vitest run`: 227/227 pass - `eslint`: clean Co-authored-by: Cursor <cursoragent@cursor.com>
All remaining tasks for this PRMust fix before merge
Deferred (new issue)
|
sverdlov93
left a comment
There was a problem hiding this comment.
LGTM. Download defaults, if-no-files-found, distribute docs, and forward-compatible [LATEST] resolution all look correct.
Minor non-blocking suggestions commented separately (dead code comment, action.yml description length).
Auth-side [LATEST] deferred to jfrog/fly-service#1186.
Overview
Implements issue #54 —
[LATEST]support, docs alignment, and zero-match glob handling for the generic artifact sub-actions, building on the fly-service[LATEST]resolution work in fly-service#1104 and the desktop counterpart in fly-desktop#205 (item 5).Partially addresses #54. Public-path
[LATEST]resolution is fully wired here; the authenticateddownloadsub-action defaultsversionto[LATEST]so the same workflow keeps working with no edits the moment fly-service ships auth-side resolution (#1104 item 2). Until then, omittingversionreturns an actionable error from the Fly CLI; the README's#### [LATEST] resolutionsection spells out today vs tomorrow.How it works
1.
download.versionis now optional → defaults to[LATEST]download/action.ymlflipsversionfromrequired: truetorequired: false. When omitted,transfer.tsresolves the value via the newresolveVersion()helper:[LATEST]distributestep): the Fly server resolves via302 Found+Location: …/{concrete}/{file}+Cache-Control: no-store. Redirect is never CDN-cached; the concrete URL it lands on is independently cacheable as immutable artifact data.downloaduses): not yet resolved byfly-service(tracked in #1104 item 2). Until that lands, the Fly CLI returns an actionable error pointing at the public URL.upload/action.ymlkeepsrequired: true, but server also rejects[LATEST]writes with400 Bad Request)[LATEST]on download)The
LATEST_VERSION = "[LATEST]"constant is introduced insrc/constants.tsto avoid magic strings.2. Job summary shows the concrete version, not literal
[LATEST]When the resolved version is
[LATEST],runTransferissues a best-effortHEADagainst the authenticated generic endpoint with redirects disabled, then parses the concrete version from theLocationheader of the 302 response. The CLI invocation still receives literal[LATEST](the wire-format the issue calls out); the resolved version is used only forappendTransferResultsand the success log line.Resolution is graceful — on 404 (no version uploaded yet), missing/malformed
Locationheader, or network error, we fall back to displaying the literal[LATEST]and never block the actual download. Today the auth path returns 404 (no resolution there yet), so the function reliably falls through to the literal-token display; oncefly-serviceships #1104 item 2 it will start producing concrete versions with no client-side change.3. Distribute + public URL documentation
The
distributesub-action already worked end-to-end but was undocumented in the README. Added a dedicated section covering:https://{tenant}.jfrog.io/public/generic/{name}/{version}/{file}[LATEST]works on the public path today:curl -LO https://{tenant}.jfrog.io/public/generic/{name}/[LATEST]/{file}(auth-path resolution is future work — see the[LATEST]resolution table)[LATEST]is download-only and rejected onupload/distribute(now enforced infly-servicePR #1174 on the upload side too)distribute/action.ymldescription now mentions the public URL pattern up front.4. Zero-match glob handling — new
if-no-files-foundinput onupload(closes fly-desktop #205 item 5)The Fly CLI already supports
--if-no-files-found error|warn|ignore(added in fly-desktop), but the action didn't expose it — every zero-match upload was a hard failure. Added an optionalif-no-files-foundinput onupload/action.ymlthat forwards the value to the CLI:Behaviour:
errorapplies → no behaviour change for existing workflows.error/warn/ignore→ forwarded as--if-no-files-found <value>.Validation lives in
src/upload.ts'sbuildUploadExtraArgs()sotransfer.tsstays generic —downloadhas no equivalent flag. Failure path usescore.setFailed, never escapes as an unhandled rejection.Tests
runTransferintegration:[LATEST]happy path (302 → 2.5.0 in summary), 404 fallback to literal, no-resolve when explicit versionresolveLatestVersionForDisplayunit: 302 happy path, percent-decoded versions, trailing-slash normalization, 404 fallback, missing Location, malformed path, network throwisLatestTokencase-insensitive matchingresolveVersiontable — concrete pass-through, empty download →[LATEST], empty upload → throwbuildUploadExtraArgs+runUpload(new in this commit): omitted input, all three valid values viait.each, trimmed whitespace, case-sensitive rejection (WARNinvalid), unknown value rejection (none,ohno),runTransfernot invoked on validation error227 / 227 tests pass (was 218 — added 11 covering the new input). ESLint and
tsc --noEmitclean.Notes
[LATEST]once at the start. If a new upload lands mid-download, the CLI may grab a newer version while the summary shows the version-at-start. This is display-only metadata; download correctness is unaffected. A "resolve up-front and pin concrete version through the CLI" variant was considered (Option B) and rejected to stay literal to the issue text ("verify omitted version sends[LATEST]to the CLI").rg artifactory/api/genericreturns zero hits in this repo — the CLI binary download already uses/public/generic/fly-client/[LATEST], anddistribute-core.tsalready uses/fly/api/v1/artifacts/distribute. Item 1 of the issue is satisfied as-is.headers["location"] ?? headers["Location"]collapsed toheaders["location"]. Node lowercases all HTTP/1 header names per RFC 7230 §3.2; the second branch was unreachable. No test changes needed; the existing tests already use lowercase.lib/upload.jsandlib/download.jsrebundled by esbuild — these are the runtime artifacts GitHub Actions executes. Required for the change to take effect.package-lock.jsonversion bump from 1.6.1 → 1.6.4 — caught up withpackage.json(was stale before this PR).Made with Cursor