diff --git a/.github/workflows/auto-build-publish.yml b/.github/workflows/auto-build-publish.yml index 8a19842b4..dea67df9c 100644 --- a/.github/workflows/auto-build-publish.yml +++ b/.github/workflows/auto-build-publish.yml @@ -22,7 +22,9 @@ jobs: strategy: fail-fast: false matrix: - os: [ ubuntu, windows, macos ] + # macOS omitted: local Artifactory bootstrap on GitHub-hosted macOS is broken + # (JGC-413; same skip as jfrog-cli). Per-step skips still provision a runner. + os: [ ubuntu, windows ] cli-version: [ "latest", "2.66.0" ] runs-on: ${{ matrix.os }}-latest steps: @@ -31,8 +33,13 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} + # This repo is a Node action and has no go.mod. install-go-with-cache@main + # reads go.mod when go-version is unset (#28), which fails Setup Go immediately. + # local-rt-setup (used by install-local-artifactory) requires Go 1.24+. - name: Setup Go with cache uses: jfrog/.github/actions/install-go-with-cache@main + with: + go-version: "1.24" - name: Install local Artifactory uses: jfrog/.github/actions/install-local-artifactory@main @@ -84,4 +91,4 @@ jobs: - name: Add npm modules to local build-info run: | jf npm-config --repo-resolve npm-remote - jf npm install \ No newline at end of file + jf npm install diff --git a/.github/workflows/frogbot-scan-repository.yml b/.github/workflows/frogbot-scan-repository.yml index 19936936a..5294a1176 100644 --- a/.github/workflows/frogbot-scan-repository.yml +++ b/.github/workflows/frogbot-scan-repository.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Setup Go with cache uses: jfrog/.github/actions/install-go-with-cache@main + with: + go-version: "1.24" - uses: jfrog/frogbot@v2 env: diff --git a/README.md b/README.md index cfbe42b42..9cee27460 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,8 @@ It is also possible to set the latest JFrog CLI version by adding the _version_ version: latest ``` +With `download-repository`, prefer a concrete `version: X.Y.Z` over `latest`. See [Downloading JFrog CLI from Artifactory](#downloading-jfrog-cli-from-artifactory). + | Important: Only JFrog CLI versions 1.46.4 or above are supported. | |-------------------------------------------------------------------| @@ -417,10 +419,17 @@ In this example, each job builds and publishes a different service from the mono If your agent has no Internet access, you can configure the workflow to download JFrog CLI from a [remote repository](https://www.jfrog.com/confluence/display/JFROG/Remote+Repositories) in your JFrog Artifactory, which is configured to proxy the official download URL. +> [!NOTE] +> With `download-repository`, prefer a concrete `version: X.Y.Z` over `latest`. +> +> `latest` is not resolved to a version number. It becomes the literal path segment `[RELEASE]` in the download URL (`v2/[RELEASE]/jfrog-cli-.../jfrog`), and a generic repository serves that path like any other. If the repository has **Store Artifacts Locally** enabled (the default), the binary returned for that path is cached under it, so later runs can keep receiving that same binary instead of a newer CLI. A concrete version avoids this, because every version has its own immutable path. +> +> Jobs that can reach the internet and want the newest CLI: omit `download-repository`. + Here's how you do this: 1. Create a remote repository in Artifactory. Name the repository jfrog-cli-remote and set its URL to https://releases.jfrog.io/artifactory/jfrog-cli/ -2. Set _download-repository_ input to jfrog-cli-remote: +2. Set _download-repository_ input to jfrog-cli-remote and pin `version`: ```yml - uses: jfrog/setup-jfrog-cli@v4 @@ -430,6 +439,7 @@ Here's how you do this: JF_ACCESS_TOKEN: ${{ secrets.JF_ACCESS_TOKEN }} with: + version: X.Y.Z download-repository: jfrog-cli-remote ``` diff --git a/action.yml b/action.yml index d1a779ea2..5e17f6127 100644 --- a/action.yml +++ b/action.yml @@ -3,11 +3,11 @@ description: "Install and configure JFrog CLI." author: "JFrog" inputs: version: - description: "JFrog CLI Version" + description: "JFrog CLI Version. A concrete X.Y.Z is recommended when download-repository points to a remote repository that stores artifacts locally, since latest is requested as the v2/[RELEASE] path and can be served from cache." default: "2.91.0" required: false download-repository: - description: "Remote repository in Artifactory pointing to 'https://releases.jfrog.io/artifactory/jfrog-cli'. Use this parameter in case you don't have an Internet access." + description: "Remote repository in Artifactory pointing to 'https://releases.jfrog.io/artifactory/jfrog-cli'. Use this parameter in case you don't have an Internet access. When the repository stores artifacts locally, prefer a concrete version over latest: latest is requested as the literal v2/[RELEASE] path, and the binary cached under that path can keep being served on later runs." required: false oidc-provider-name: description: "Provider Name's value that was set in OpenId Connect integration in the JFrog platform." diff --git a/lib/cleanup.js b/lib/cleanup.js index adb012d2e..c0acda15e 100644 --- a/lib/cleanup.js +++ b/lib/cleanup.js @@ -34,6 +34,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge Object.defineProperty(exports, "__esModule", { value: true }); exports.checkConnectionToArtifactory = checkConnectionToArtifactory; const core = __importStar(require("@actions/core")); +const fs_1 = require("fs"); +const path_1 = require("path"); const utils_1 = require("./utils"); const job_summary_1 = require("./job-summary"); const evidence_collection_1 = require("./evidence-collection"); @@ -137,8 +139,8 @@ function collectAndPublishBuildInfoIfNeeded() { // We allow this step to fail, and we don't want to fail the entire build publish if they do. try { core.startGroup('Collect the Git information'); - const gitDir = require('path').join(workingDirectory, '.git'); - if (require('fs').existsSync(gitDir)) { + const gitDir = (0, path_1.join)(workingDirectory, '.git'); + if ((0, fs_1.existsSync)(gitDir)) { yield utils_1.Utils.runCli(['rt', 'build-add-git'], { cwd: workingDirectory }); } else { diff --git a/lib/utils.js b/lib/utils.js index 3e3f302ca..0cb235467 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -82,6 +82,7 @@ class Utils { let version = core.getInput(Utils.CLI_VERSION_ARG); let cliRemote = core.getInput(Utils.CLI_REMOTE_ARG); const isLatestVer = version === Utils.LATEST_CLI_VERSION; + Utils.logIfLatestDownloadedFromRemote(version, cliRemote); if (!isLatestVer && (0, semver_1.lt)(version, this.MIN_CLI_VERSION)) { throw new Error('Requested to download JFrog CLI version ' + version + ' but must be at least ' + this.MIN_CLI_VERSION); } @@ -168,6 +169,16 @@ class Utils { } return `${artifactoryUrl}/${downloadDetails.repository}/v${major}/${version}/${architecture}/${fileName}`; } + /** + * Log when latest is resolved through an Artifactory repository. + * [RELEASE] is part of the artifact path, so a remote repository caches it like any other file + * and keeps serving the first version it resolved. Pin a concrete version instead. + */ + static logIfLatestDownloadedFromRemote(version, cliRemote) { + if (cliRemote && version === Utils.LATEST_CLI_VERSION) { + core.info(Utils.LATEST_FROM_REMOTE_INFO); + } + } // Get Config Tokens created on your local machine using JFrog CLI. // The Tokens configured with JF_ENV_ environment variables. static getConfigTokens() { @@ -470,7 +481,7 @@ class Utils { } /** * If enable-package-alias is true and GITHUB_PATH is set, runs `jf package-alias install` - * and appends the alias bin directory to GITHUB_PATH so subsequent steps intercept mvn, npm, go, etc. + * and adds the alias bin directory to PATH via core.addPath so subsequent steps intercept mvn, npm, go, etc. * On failure (e.g. older CLI without package-alias), logs a warning and does not fail the job. */ static setupPackageAliasIfRequested() { @@ -513,8 +524,8 @@ class Utils { return; } const aliasBinDir = Utils.getPackageAliasBinDir(); - (0, fs_1.appendFileSync)(githubPath, aliasBinDir + '\n'); - core.info('Package aliases installed and "' + aliasBinDir + '" appended to GITHUB_PATH.'); + core.addPath(aliasBinDir); + core.info('Package aliases installed and "' + aliasBinDir + '" added to PATH.'); }); } } @@ -534,6 +545,8 @@ Utils.MIN_CLI_VERSION = '1.46.4'; Utils.LATEST_CLI_VERSION = 'latest'; // The value in the download URL to set to get the latest version Utils.LATEST_RELEASE_VERSION = '[RELEASE]'; +// Logged when version=latest is downloaded through a remote repository, which caches the literal [RELEASE] path +Utils.LATEST_FROM_REMOTE_INFO = 'download-repository is set with version=latest, so the CLI is requested from the literal path v2/[RELEASE] instead of a version number. If that repository stores artifacts locally, the binary cached under this path can keep being served on later runs. Use a concrete version to download from a per-version path, or omit download-repository if the runner can reach releases.jfrog.io.'; // Placeholder CLI version to use to keep 'latest' in cache. Utils.LATEST_SEMVER = '100.100.100'; // The default server id name for separate env config diff --git a/src/cleanup.ts b/src/cleanup.ts index 5360ac177..cefeae4a4 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,4 +1,6 @@ import * as core from '@actions/core'; +import { existsSync } from 'fs'; +import { join } from 'path'; import { Utils } from './utils'; import { JobSummary } from './job-summary'; import { collectEvidences } from './evidence-collection'; @@ -105,8 +107,8 @@ async function collectAndPublishBuildInfoIfNeeded() { // We allow this step to fail, and we don't want to fail the entire build publish if they do. try { core.startGroup('Collect the Git information'); - const gitDir: string = require('path').join(workingDirectory, '.git'); - if (require('fs').existsSync(gitDir)) { + const gitDir: string = join(workingDirectory, '.git'); + if (existsSync(gitDir)) { await Utils.runCli(['rt', 'build-add-git'], { cwd: workingDirectory }); } else { core.info('No .git directory found. Skipping Git information collection.'); diff --git a/src/evidence-collection.ts b/src/evidence-collection.ts index bf94c6cc3..0963b8136 100644 --- a/src/evidence-collection.ts +++ b/src/evidence-collection.ts @@ -2,7 +2,6 @@ import * as core from '@actions/core'; import { Utils } from './utils'; import { HttpClient, HttpClientResponse } from '@actions/http-client'; import { OutgoingHttpHeaders } from 'http'; -import { JfrogCredentials } from './types'; import { promises as fs } from 'fs'; import * as path from 'path'; diff --git a/src/job-summary.ts b/src/job-summary.ts index 34e7cdd25..f33844af2 100644 --- a/src/job-summary.ts +++ b/src/job-summary.ts @@ -1,14 +1,11 @@ import * as core from '@actions/core'; import { gte } from 'semver'; -import { Octokit } from '@octokit/core'; -import { OctokitResponse } from '@octokit/types/dist-types/OctokitResponse'; import * as github from '@actions/github'; import { promisify } from 'util'; import { gzip } from 'zlib'; import path from 'path'; import { existsSync, promises as fs } from 'fs'; import { HttpClient, HttpClientResponse } from '@actions/http-client'; -import { OutgoingHttpHeaders } from 'http'; import { tmpdir } from 'os'; import { Utils } from './utils'; diff --git a/src/utils.ts b/src/utils.ts index 8ff668407..edbdb9e8a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,6 +30,9 @@ export class Utils { public static readonly LATEST_CLI_VERSION: string = 'latest'; // The value in the download URL to set to get the latest version private static readonly LATEST_RELEASE_VERSION: string = '[RELEASE]'; + // Logged when version=latest is downloaded through a remote repository, which caches the literal [RELEASE] path + public static readonly LATEST_FROM_REMOTE_INFO: string = + 'download-repository is set with version=latest, so the CLI is requested from the literal path v2/[RELEASE] instead of a version number. If that repository stores artifacts locally, the binary cached under this path can keep being served on later runs. Use a concrete version to download from a per-version path, or omit download-repository if the runner can reach releases.jfrog.io.'; // Placeholder CLI version to use to keep 'latest' in cache. public static readonly LATEST_SEMVER: string = '100.100.100'; // The default server id name for separate env config @@ -108,6 +111,7 @@ export class Utils { let version: string = core.getInput(Utils.CLI_VERSION_ARG); let cliRemote: string = core.getInput(Utils.CLI_REMOTE_ARG); const isLatestVer: boolean = version === Utils.LATEST_CLI_VERSION; + Utils.logIfLatestDownloadedFromRemote(version, cliRemote); if (!isLatestVer && lt(version, this.MIN_CLI_VERSION)) { throw new Error('Requested to download JFrog CLI version ' + version + ' but must be at least ' + this.MIN_CLI_VERSION); @@ -199,6 +203,17 @@ export class Utils { return `${artifactoryUrl}/${downloadDetails.repository}/v${major}/${version}/${architecture}/${fileName}`; } + /** + * Log when latest is resolved through an Artifactory repository. + * [RELEASE] is part of the artifact path, so a remote repository caches it like any other file + * and keeps serving the first version it resolved. Pin a concrete version instead. + */ + public static logIfLatestDownloadedFromRemote(version: string, cliRemote: string): void { + if (cliRemote && version === Utils.LATEST_CLI_VERSION) { + core.info(Utils.LATEST_FROM_REMOTE_INFO); + } + } + // Get Config Tokens created on your local machine using JFrog CLI. // The Tokens configured with JF_ENV_ environment variables. public static getConfigTokens(): Set { diff --git a/test/main.spec.ts b/test/main.spec.ts index 19258b5a4..1923c6933 100644 --- a/test/main.spec.ts +++ b/test/main.spec.ts @@ -2,6 +2,7 @@ import * as os from 'os'; import * as path from 'path'; import * as core from '@actions/core'; import * as exec from '@actions/exec'; +import * as toolCache from '@actions/tool-cache'; import { existsSync, unlinkSync } from 'fs'; import { Utils } from '../src/utils'; @@ -10,6 +11,7 @@ import { DownloadDetails, JfrogCredentials } from '../src/types'; jest.mock('os'); jest.mock('@actions/exec'); jest.mock('@actions/core'); +jest.mock('@actions/tool-cache'); const DEFAULT_CLI_URL: string = 'https://releases.jfrog.io/artifactory/jfrog-cli/'; const CUSTOM_CLI_URL: string = 'http://127.0.0.1:8081/artifactory/jfrog-cli-remote/'; @@ -259,6 +261,139 @@ describe('JFrog CLI V2 URL Tests', () => { }); }); +describe('JFrog CLI latest URL uses literal [RELEASE]', () => { + const myOs: jest.Mocked = os as any; + + beforeEach(() => { + myOs.platform.mockImplementation(() => 'linux'); + myOs.arch.mockImplementation(() => 'amd64'); + }); + + test('Public download URL keeps [RELEASE] instead of a version number', () => { + const cliUrl: string = Utils.getCliUrl(Utils.LATEST_CLI_VERSION, 'jfrog', Utils.DEFAULT_DOWNLOAD_DETAILS); + expect(cliUrl).toBe(DEFAULT_CLI_URL + 'v2/[RELEASE]/jfrog-cli-linux-amd64/jfrog'); + expect(cliUrl).not.toMatch(/v2\/\d+\.\d+\.\d+\//); + }); + + test('download-repository URL still uses the literal [RELEASE] path', () => { + process.env.JF_ENV_LOCAL = V2_CONFIG; + const cliUrl: string = Utils.getCliUrl( + Utils.LATEST_CLI_VERSION, + 'jfrog', + Utils.extractDownloadDetails('jfrog-cli-remote', {} as JfrogCredentials), + ); + expect(cliUrl).toBe(CUSTOM_CLI_URL + 'v2/[RELEASE]/jfrog-cli-linux-amd64/jfrog'); + expect(cliUrl).toContain('/v2/[RELEASE]/'); + expect(cliUrl).not.toMatch(/v2\/\d+\.\d+\.\d+\//); + }); +}); + +describe('logIfLatestDownloadedFromRemote', () => { + beforeEach(() => { + (core.info as jest.Mock).mockClear(); + }); + + test('Logs info when latest is downloaded from an Artifactory repository', () => { + Utils.logIfLatestDownloadedFromRemote(Utils.LATEST_CLI_VERSION, 'jfrog-cli-remote'); + expect(core.info).toHaveBeenCalledWith(Utils.LATEST_FROM_REMOTE_INFO); + }); + + test('Does not log when version is pinned', () => { + Utils.logIfLatestDownloadedFromRemote('2.91.0', 'jfrog-cli-remote'); + expect(core.info).not.toHaveBeenCalled(); + }); + + test('Does not log when download-repository is unset', () => { + Utils.logIfLatestDownloadedFromRemote(Utils.LATEST_CLI_VERSION, ''); + expect(core.info).not.toHaveBeenCalled(); + }); + + test('Does not log when version is Latest with different casing', () => { + Utils.logIfLatestDownloadedFromRemote('Latest', 'jfrog-cli-remote'); + expect(core.info).not.toHaveBeenCalled(); + }); +}); + +describe('getAndAddCliToPath latest + download-repository', () => { + const myCore: jest.Mocked = core as any; + const myOs: jest.Mocked = os as any; + const myToolCache: jest.Mocked = toolCache as any; + + beforeEach(() => { + myCore.info.mockClear(); + myOs.platform.mockImplementation(() => 'linux'); + myOs.arch.mockImplementation(() => 'amd64'); + myToolCache.downloadTool.mockResolvedValue('/tmp/downloaded-jf'); + jest.spyOn(Utils, 'cacheAndAddPath').mockResolvedValue(); + }); + + afterEach(() => { + jest.spyOn(Utils, 'cacheAndAddPath').mockRestore(); + }); + + test('logs and downloads the literal [RELEASE] URL when latest and download-repository are set', async () => { + myCore.getInput.mockImplementation((name: string) => { + if (name === Utils.CLI_VERSION_ARG) { + return Utils.LATEST_CLI_VERSION; + } + if (name === Utils.CLI_REMOTE_ARG) { + return 'jfrog-cli-remote'; + } + return ''; + }); + process.env.JF_ENV_LOCAL = V2_CONFIG; + + await Utils.getAndAddCliToPath({} as JfrogCredentials); + + expect(myCore.info).toHaveBeenCalledWith(Utils.LATEST_FROM_REMOTE_INFO); + expect(myToolCache.downloadTool).toHaveBeenCalledWith( + CUSTOM_CLI_URL + 'v2/[RELEASE]/jfrog-cli-linux-amd64/jfrog', + undefined, + 'Basic YWRtaW46cGFzc3dvcmQ=', + ); + }); + + test('does not log when version is latest but download-repository is empty', async () => { + myCore.getInput.mockImplementation((name: string) => { + if (name === Utils.CLI_VERSION_ARG) { + return Utils.LATEST_CLI_VERSION; + } + if (name === Utils.CLI_REMOTE_ARG) { + return ''; + } + return ''; + }); + + await Utils.getAndAddCliToPath({} as JfrogCredentials); + + expect(myCore.info).not.toHaveBeenCalledWith(Utils.LATEST_FROM_REMOTE_INFO); + expect(myToolCache.downloadTool).toHaveBeenCalledWith(DEFAULT_CLI_URL + 'v2/[RELEASE]/jfrog-cli-linux-amd64/jfrog', undefined, undefined); + }); + + test('does not log when version is pinned and download-repository is set', async () => { + myCore.getInput.mockImplementation((name: string) => { + if (name === Utils.CLI_VERSION_ARG) { + return '2.91.0'; + } + if (name === Utils.CLI_REMOTE_ARG) { + return 'jfrog-cli-remote'; + } + return ''; + }); + process.env.JF_ENV_LOCAL = V2_CONFIG; + myToolCache.find.mockReturnValue(''); + + await Utils.getAndAddCliToPath({} as JfrogCredentials); + + expect(myCore.info).not.toHaveBeenCalledWith(Utils.LATEST_FROM_REMOTE_INFO); + expect(myToolCache.downloadTool).toHaveBeenCalledWith( + CUSTOM_CLI_URL + 'v2/2.91.0/jfrog-cli-linux-amd64/jfrog', + undefined, + 'Basic YWRtaW46cGFzc3dvcmQ=', + ); + }); +}); + test('Extract download details Tests', () => { for (let config of [V1_CONFIG, V2_CONFIG]) { process.env.JF_ENV_LOCAL = config;