Skip to content

Commit 794be1d

Browse files
Sync product changes from dev into v2 for release (#645)
Brings the post-2026-04-30 non-e2e product work onto v2 so the release pipeline can build and publish the new version: - #638: authenticate JFrog CLI download for OIDC service connections (JFrogToolsInstaller) - Default JFrog CLI 2.111.0 / pluginVersion 2.14.2 - Node 22 in the test matrix (RTECO-813) Also aligns e2e-plugin-tests.yml and trigger-ado-pipeline.sh with dev so the release step `git merge origin/dev` is conflict-free. No change to extension task runtime behavior beyond the items above.
1 parent bdd1501 commit 794be1d

8 files changed

Lines changed: 565 additions & 51 deletions

File tree

.github/workflows/e2e-plugin-tests.yml

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@ on:
7878
required: false
7979
default: 'false'
8080

81-
# Only one E2E run at a time — prevents two PRs racing to trigger the same
82-
# ADO pipeline simultaneously (which the dev org's parallel-job quota
83-
# would queue anyway).
81+
# One E2E run at a time per PR. A new label-trigger on the same PR cancels
82+
# the previous queued/in-progress run so the latest commit is always tested.
83+
# Across different PRs, runs are independent and execute in parallel.
8484
concurrency:
85-
group: e2e-plugin-tests
86-
cancel-in-progress: false
85+
group: e2e-plugin-tests-${{ github.event.pull_request.number || github.run_id }}
86+
cancel-in-progress: true
8787

8888
# ======================================================================
8989
# JOB 1 — Build the .vsix from the PR branch and upload as artifact.
@@ -212,23 +212,6 @@ jobs:
212212
retention-days: 14
213213
if-no-files-found: error
214214

215-
- name: Publish .vsix to Marketplace
216-
if: ${{ env.MARKETPLACE_PAT != '' }}
217-
env:
218-
MARKETPLACE_PAT: ${{ secrets.ADO_E2E_MARKETPLACE_PAT }}
219-
run: |
220-
set -euo pipefail
221-
vsix_path="$(ls -1 ./*.vsix | head -1)"
222-
echo "Publishing $vsix_path to Marketplace..."
223-
npx tfx extension publish \
224-
--vsix "$vsix_path" \
225-
--token "$MARKETPLACE_PAT" \
226-
--no-wait-validation
227-
echo "Published successfully."
228-
echo "Waiting 90s for Marketplace async validation and ADO extension cache to propagate..."
229-
sleep 90
230-
echo "Done waiting — ADO pipelines will now run against the freshly published extension."
231-
232215
- name: Write install instructions to job summary
233216
env:
234217
VSIX_VERSION: ${{ steps.build_vsix.outputs.vsix_version }}
@@ -334,7 +317,7 @@ jobs:
334317
- name: Checkout (for trigger script)
335318
uses: actions/checkout@v4
336319
with:
337-
ref: ${{ github.event.pull_request.head.sha || github.sha }}
320+
ref: ${{ github.event.pull_request.base.sha || github.sha }}
338321

339322
- name: Trigger ADO sanity pipeline and wait for result
340323
# The sanity pipeline (.pipelines/ado-sanity-pipeline.yml) tests:
@@ -406,7 +389,7 @@ jobs:
406389
- name: Checkout (for trigger script)
407390
uses: actions/checkout@v4
408391
with:
409-
ref: ${{ github.event.pull_request.head.sha || github.sha }}
392+
ref: ${{ github.event.pull_request.base.sha || github.sha }}
410393

411394
- name: Trigger ADO OIDC test pipeline and wait for result
412395
# The OIDC pipeline covers:

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
fail-fast: false
2222
matrix:
2323
os: [ ubuntu, windows ]
24-
node-version: [ '16', '20', '24' ]
24+
node-version: [ '16', '20', '22', '24' ]
2525
include:
2626
- os: windows
2727
extraSkipTests: ",conan"

buildScripts/trigger-ado-pipeline.sh

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ set -eu
77
# ADO_ORG - Azure DevOps organization name (e.g. vigneshc0742)
88
# ADO_PROJECT - Azure DevOps project name (e.g. ecomatrix-test)
99
# ADO_PIPELINE_ID - Pipeline definition ID (e.g. 63)
10-
# ADO_PAT - PAT with Build Read+Execute and Project Read scopes
10+
# ADO_PAT - PAT with Build Read+Execute and Project Read scopes only
1111
#
1212
# Optional environment variables:
1313
# GH_PR_NUMBER - GitHub PR number passed into pipeline as a variable (default: 0)
@@ -43,20 +43,53 @@ echo "=============================================="
4343
RESPONSE_FILE=$(mktemp)
4444
trap 'rm -f "$RESPONSE_FILE"' EXIT
4545

46-
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" -X POST \
47-
"${API_BASE}/pipelines/${ADO_PIPELINE_ID}/runs?api-version=7.1" \
48-
-H "${AUTH_HEADER}" \
49-
-H "Content-Type: application/json" \
50-
-d "{
51-
\"variables\": {
52-
\"GH_PR_NUMBER\": { \"value\": \"${GH_PR_NUMBER}\", \"isSecret\": false },
53-
\"GH_COMMIT_SHA\": { \"value\": \"${GH_COMMIT_SHA}\", \"isSecret\": false }
54-
}
55-
}" || echo "000")
46+
# A freshly (re)installed private extension is not immediately resolvable by
47+
# ADO's pipeline queue-time validator: it returns HTTP 400 with
48+
# "A task is missing. The pipeline references a task called '...E2E'" until the
49+
# org's task catalog catches up. This is an eventual-consistency lag, not a
50+
# real error, so retry the trigger for up to TRIGGER_RETRY_MINUTES, once per
51+
# minute, ONLY for that specific 400. All other statuses fail immediately.
52+
TRIGGER_RETRY_MINUTES="${TRIGGER_RETRY_MINUTES:-15}"
53+
TRIGGER_RETRY_INTERVAL_SECONDS="${TRIGGER_RETRY_INTERVAL_SECONDS:-60}"
54+
TRIGGER_DEADLINE=$(( $(date +%s) + TRIGGER_RETRY_MINUTES * 60 ))
55+
TRIGGER_ATTEMPT=0
56+
57+
while : ; do
58+
TRIGGER_ATTEMPT=$(( TRIGGER_ATTEMPT + 1 ))
59+
60+
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" -X POST \
61+
"${API_BASE}/pipelines/${ADO_PIPELINE_ID}/runs?api-version=7.1" \
62+
-H "${AUTH_HEADER}" \
63+
-H "Content-Type: application/json" \
64+
-d "{
65+
\"variables\": {
66+
\"GH_PR_NUMBER\": { \"value\": \"${GH_PR_NUMBER}\", \"isSecret\": false },
67+
\"GH_COMMIT_SHA\": { \"value\": \"${GH_COMMIT_SHA}\", \"isSecret\": false }
68+
}
69+
}" || echo "000")
70+
71+
RUN_RESPONSE="$(cat "$RESPONSE_FILE")"
72+
73+
if [ "$HTTP_STATUS" = "200" ]; then
74+
break
75+
fi
5676

57-
RUN_RESPONSE="$(cat "$RESPONSE_FILE")"
77+
# Retry only the transient "task is missing" 400 (extension still
78+
# propagating in the org); every other failure is terminal.
79+
if [ "$HTTP_STATUS" = "400" ] && printf '%s' "$RUN_RESPONSE" | grep -q "A task is missing"; then
80+
if [ "$(date +%s)" -lt "$TRIGGER_DEADLINE" ]; then
81+
echo "[$(date -u '+%H:%M:%S')] Trigger attempt ${TRIGGER_ATTEMPT}: HTTP 400 'A task is missing' — the E2E extension is still propagating in org '${ADO_ORG}'. Retrying in ${TRIGGER_RETRY_INTERVAL_SECONDS}s (up to ${TRIGGER_RETRY_MINUTES} min)..."
82+
sleep "$TRIGGER_RETRY_INTERVAL_SECONDS"
83+
continue
84+
fi
85+
echo "ERROR: Pipeline trigger still failing with 'A task is missing' after ${TRIGGER_RETRY_MINUTES} minutes."
86+
echo " The private E2E extension did not become resolvable in org '${ADO_ORG}' within the retry window."
87+
echo " Response :"
88+
echo "${RUN_RESPONSE}" | head -c 2000
89+
echo ""
90+
exit 1
91+
fi
5892

59-
if [ "$HTTP_STATUS" != "200" ]; then
6093
echo "ERROR: Pipeline trigger failed."
6194
echo " HTTP status : ${HTTP_STATUS}"
6295
echo " Endpoint : ${API_BASE}/pipelines/${ADO_PIPELINE_ID}/runs?api-version=7.1"
@@ -71,7 +104,7 @@ if [ "$HTTP_STATUS" != "200" ]; then
71104
*) echo " Hint: see the response body above for the ADO error message." ;;
72105
esac
73106
exit 1
74-
fi
107+
done
75108

76109
RUN_ID=$(echo "$RUN_RESPONSE" | jq -r '.id // empty')
77110
RUN_URL=$(echo "$RUN_RESPONSE" | jq -r '._links.web.href // empty')

jfrog-tasks-utils/utils.d.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ declare module '@jfrog/tasks-utils' {
66
runTaskFunc: (cliPath: string) => void | Promise<void>,
77
cliVersion?: string,
88
cliDownloadUrl?: string,
9-
cliAuthHandlers?: ifm.IRequestHandler[],
9+
cliAuthHandlers?: ifm.IRequestHandler[] | (() => Promise<ifm.IRequestHandler[]>),
1010
): void;
1111
export function quote(str: string): string;
1212
export function downloadCli(cliDownloadUrl?: string, cliAuthHandlers?: ifm.IRequestHandler[], cliVersion?: string): Promise<string>;
@@ -63,6 +63,10 @@ declare module '@jfrog/tasks-utils' {
6363
export function addTrailingSlashIfNeeded(str: string): string;
6464
export function buildCliArtifactoryDownloadUrl(rtUrl: string, repoName: string, cliVersion?: string): string;
6565
export function createAuthHandlers(serviceConnection: string): ifm.IRequestHandler[];
66+
export function createCliDownloadAuthHandlers(serviceConnection: string, exchangeFn?: (service: string, platformUrl: string, oidcProviderName: string) => Promise<string>,): Promise<ifm.IRequestHandler[]>;
67+
export function exchangeOidcTokenViaRest(service: string, platformUrl: string, oidcProviderName: string): Promise<string>;
68+
export function isOidcConnection(serviceConnection: string): boolean;
69+
export function resolvePlatformUrl(service: string): string;
6670
export function stripTrailingSlash(str: string): string;
6771
export function writeSpecContentToSpecPath(specSource: string, specPath: string): void;
6872
export function addCommonGenericParams(cliCommand: string, specPath: string): string;

jfrog-tasks-utils/utils.js

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const fileName = getCliExecutableName();
1212
const jfrogCliToolName = 'jf';
1313
const cliPackage = 'jfrog-cli-' + getArchitecture();
1414
const fallbackCliVersion = '2.99.0';
15-
let defaultJfrogCliVersion = '2.103.0';
15+
let defaultJfrogCliVersion = '2.111.0';
1616

1717
/**
1818
* Executes an HTTP request with retry logic for 5xx errors.
@@ -144,7 +144,7 @@ const minCustomCliVersion = '2.10.0';
144144
const minSupportedStdinSecretCliVersion = '2.36.0';
145145
const minSupportedServerIdEnvCliVersion = '2.37.0';
146146
const minSupportedOidcCliVersion = '2.75.0';
147-
const pluginVersion = '2.14.1';
147+
const pluginVersion = '2.14.2';
148148
const buildAgent = 'jfrog-azure-devops-extension';
149149

150150
/**
@@ -201,6 +201,10 @@ module.exports = {
201201
isToolExists: isToolExists,
202202
buildCliArtifactoryDownloadUrl: buildCliArtifactoryDownloadUrl,
203203
createAuthHandlers: createAuthHandlers,
204+
createCliDownloadAuthHandlers: createCliDownloadAuthHandlers,
205+
exchangeOidcTokenViaRest: exchangeOidcTokenViaRest,
206+
isOidcConnection: isOidcConnection,
207+
resolvePlatformUrl: resolvePlatformUrl,
204208
taskDefaultCleanup: taskDefaultCleanup,
205209
writeSpecContentToSpecPath: writeSpecContentToSpecPath,
206210
stripTrailingSlash: stripTrailingSlash,
@@ -285,7 +289,11 @@ function getCliPath(cliDownloadUrl, cliAuthHandlers, cliVersion) {
285289
} else {
286290
const errMsg = generateDownloadCliErrorMessage(cliDownloadUrl, cliVersion);
287291
createCliDirs();
288-
return downloadCli(cliDownloadUrl, cliAuthHandlers, cliVersion)
292+
// cliAuthHandlers may be an array or a provider function returning a Promise<array>.
293+
// Resolve it lazily here so that work such as an OIDC token exchange only happens
294+
// when a download is actually required — never when the CLI is already cached.
295+
return Promise.resolve(typeof cliAuthHandlers === 'function' ? cliAuthHandlers() : cliAuthHandlers)
296+
.then((resolvedHandlers) => downloadCli(cliDownloadUrl, resolvedHandlers, cliVersion))
289297
.then((cliPath) => resolve(cliPath))
290298
.catch((error) => reject(errMsg + '\n' + error));
291299
}
@@ -330,6 +338,41 @@ function createAuthHandlers(serviceConnection) {
330338
return [new credentialsHandler.BasicCredentialHandler(artifactoryUser, artifactoryPassword, false)];
331339
}
332340

341+
/**
342+
* Returns whether the given service connection uses OIDC authentication.
343+
* @param {string} serviceConnection - The service connection ID.
344+
* @returns {boolean}
345+
*/
346+
function isOidcConnection(serviceConnection) {
347+
return !!tl.getEndpointAuthorizationParameter(serviceConnection, 'oidcProviderName', true);
348+
}
349+
350+
/**
351+
* Builds the authentication handlers used to download the JFrog CLI.
352+
*
353+
* For OIDC-based service connections the credential does not exist as a static
354+
* token — it must be obtained through an OIDC token exchange. The CLI-based
355+
* exchange (exchangeOidcTokenAndSetStepVariables) cannot be used here because the
356+
* CLI is the very artifact being downloaded, so this performs a CLI-independent
357+
* REST exchange (exchangeOidcTokenViaRest) and authenticates the download with the
358+
* resulting access token. For all other connection types it falls back to the
359+
* synchronous createAuthHandlers (access token / basic / anonymous).
360+
*
361+
* @param {string} serviceConnection - The Artifactory service connection ID.
362+
* @param {(service: string, platformUrl: string, oidcProviderName: string) => Promise<string>} [exchangeFn]
363+
* - OIDC exchange implementation; injectable for testing. Defaults to exchangeOidcTokenViaRest.
364+
* @returns {Promise<Array>} Authentication handlers for the CLI download.
365+
*/
366+
async function createCliDownloadAuthHandlers(serviceConnection, exchangeFn = exchangeOidcTokenViaRest) {
367+
if (!isOidcConnection(serviceConnection)) {
368+
return createAuthHandlers(serviceConnection);
369+
}
370+
const platformUrl = resolvePlatformUrl(serviceConnection);
371+
const oidcProviderName = tl.getEndpointAuthorizationParameter(serviceConnection, 'oidcProviderName', true);
372+
const accessToken = await exchangeFn(serviceConnection, platformUrl, oidcProviderName);
373+
return [new credentialsHandler.BearerCredentialHandler(accessToken, false)];
374+
}
375+
333376
function generateDownloadCliErrorMessage(downloadUrl, cliVersion) {
334377
let errMsg = 'Failed while attempting to download JFrog CLI from ' + downloadUrl;
335378
if (downloadUrl === buildReleasesDownloadUrl(cliVersion)) {
@@ -393,11 +436,14 @@ function maskSecrets(str) {
393436
.replace(/--access-token='.*?'/g, '--access-token=***');
394437
}
395438

396-
async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) {
397-
const oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true);
398-
if (!oidcProviderName) {
399-
return undefined;
400-
}
439+
/**
440+
* Resolves the JFrog platform URL for a service connection. Prefers the explicit
441+
* 'jfrogPlatformUrl' authorization parameter and falls back to parsing it from the
442+
* service URL.
443+
* @param {string} service - The service connection ID.
444+
* @returns {string} The resolved platform URL.
445+
*/
446+
function resolvePlatformUrl(service) {
401447
const serviceUrl = tl.getEndpointUrl(service, false);
402448
let platformUrl = '';
403449
try {
@@ -408,6 +454,15 @@ async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) {
408454
if (!platformUrl || !platformUrl.trim()) {
409455
platformUrl = parsePlatformUrlFromServiceUrl(serviceUrl);
410456
}
457+
return platformUrl;
458+
}
459+
460+
async function fetchOidcTokenIfConfigured(service, cliPath, buildDir) {
461+
if (!isOidcConnection(service)) {
462+
return undefined;
463+
}
464+
const oidcProviderName = tl.getEndpointAuthorizationParameter(service, 'oidcProviderName', true);
465+
const platformUrl = resolvePlatformUrl(service);
411466
return exchangeOidcTokenAndSetStepVariables(service, platformUrl, oidcProviderName, cliPath, buildDir);
412467
}
413468

@@ -619,6 +674,58 @@ async function fetchAzureOidcToken(serviceConnectionID) {
619674
return body.oidcToken;
620675
}
621676

677+
/**
678+
* Performs an OIDC token exchange WITHOUT the JFrog CLI, via a direct REST call to
679+
* JFrog Access. Required by the JFrog Tools Installer, which must authenticate the
680+
* CLI *download* itself — at that point the CLI does not yet exist, so the
681+
* CLI-based exchange cannot be used. The request mirrors what `jf eot` sends for an
682+
* Azure provider (grant_type / subject_token_type / subject_token / provider_name /
683+
* provider_type / audience). The Azure DevOps identity mapping is matched on the ID
684+
* token's subject claim, which is carried in subject_token.
685+
*
686+
* @param {string} service - The service connection ID.
687+
* @param {string} platformUrl - The JFrog platform base URL.
688+
* @param {string} oidcProviderName - The configured OIDC provider name.
689+
* @returns {Promise<string>} The exchanged JFrog access token.
690+
*/
691+
async function exchangeOidcTokenViaRest(service, platformUrl, oidcProviderName) {
692+
const oidcAudience = tl.getEndpointAuthorizationParameter(service, 'oidcAudience', true) || 'api://AzureADTokenExchange';
693+
const idToken = await fetchAzureOidcToken(service);
694+
695+
const exchangeUrl = addTrailingSlashIfNeeded(platformUrl) + 'access/api/v1/oidc/token';
696+
const requestBody = {
697+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
698+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
699+
subject_token: idToken,
700+
provider_name: oidcProviderName,
701+
provider_type: 'Azure',
702+
audience: oidcAudience,
703+
};
704+
705+
const requestOptions = { ...getProxyConfiguration(), socketTimeout: 30000 };
706+
const httpClient = new httpm.HttpClient(buildAgent, [], requestOptions);
707+
tl.debug('Exchanging OIDC token via REST at: ' + exchangeUrl);
708+
const response = await httpClient.post(exchangeUrl, JSON.stringify(requestBody), {
709+
'Content-Type': 'application/json',
710+
});
711+
712+
const statusCode = response.message.statusCode;
713+
const responseBody = await response.readBody();
714+
if (statusCode !== 200) {
715+
throw new Error(`OIDC token exchange failed: HTTP ${statusCode}\nBody: ${responseBody}`);
716+
}
717+
/** @type {{ access_token?: string, username?: string }} */
718+
const body = JSON.parse(responseBody);
719+
if (!body.access_token) {
720+
throw new Error('OIDC token exchange response did not contain an access token.');
721+
}
722+
723+
// Publish outputs for parity with the CLI-based OIDC flow (downstream consumption / debug).
724+
tl.setVariable(oidcUserOutputName, body.username || '', true);
725+
tl.setVariable(oidcTokenOutputName, body.access_token, true);
726+
return body.access_token;
727+
}
728+
622729
async function exchangeOidcTokenAndSetStepVariables(service, serviceUrl, oidcProviderName, cliPath, buildDir) {
623730
// First validate supported CLI version
624731
let cliVersion = getCliVersion(cliPath);

tasks/JFrogToolsInstaller/toolsInstaller.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ function InstallCliAndExecuteCliTask(RunTaskCbk) {
2121
// Set the requested CLI version env to download it now, and to use in succeeding tasks.
2222
tl.setVariable(utils.pipelineRequestedCliVersionEnv, cliVersion);
2323
let downloadUrl = utils.buildCliArtifactoryDownloadUrl(artifactoryUrl, cliInstallationRepo, cliVersion);
24-
let authHandlers = utils.createAuthHandlers(artifactoryService);
25-
utils.executeCliTask(RunTaskCbk, cliVersion, downloadUrl, authHandlers);
24+
// Pass a provider (resolved lazily by executeCliTask only if a download is needed).
25+
// For OIDC service connections this performs a CLI-independent OIDC token exchange so
26+
// the CLI download itself is authenticated; other connection types use static credentials.
27+
let authHandlersProvider = () => utils.createCliDownloadAuthHandlers(artifactoryService);
28+
utils.executeCliTask(RunTaskCbk, cliVersion, downloadUrl, authHandlersProvider);
2629
}
2730

2831
async function RunTaskCbk(cliPath) {

tests/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"null-writable": "^1.0.5",
2323
"rimraf": "^6.1.2",
2424
"sync-request": "^6.1.0",
25-
"ts-node": "^10.9.1"
25+
"ts-node": "^10.9.1",
26+
"typescript": "^5.2.2"
2627
},
2728
"scripts": {
2829
"test": "npm i && mocha -r ts-node/register tests.ts -t 1000000"

0 commit comments

Comments
 (0)