diff --git a/.ado/publish.yml b/.ado/publish.yml index 594399cdd..44eda0f05 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -22,7 +22,7 @@ resources: variables: - name: nodeVersion - value: 20 + value: 24 - name: npmVersion value: 11 - name: tags @@ -56,10 +56,14 @@ extends: clean: all templateContext: + outputParentDirectory: $(Build.StagingDirectory)/out outputs: - output: pipelineArtifact - artifactName: published-packages - targetPath: $(Build.StagingDirectory)/pkg + artifactName: packed-packages + path: $(Build.StagingDirectory)/out/packed-packages + - output: pipelineArtifact + artifactName: release-api-tool + path: $(Build.StagingDirectory)/out/release-api-tool steps: - checkout: self @@ -68,11 +72,7 @@ extends: displayName: Install Node.js ${{ variables.nodeVersion }} retryCountOnTaskFailure: 1 inputs: - # 👉 NOTE: - # - we can use only versions that ship with container, otherwise we will run into nodejs installation issues. - # - as 1es bumps those versions within container automatically we need to use `.x` to not run into issues once they bump the versions. - # https://github.com/actions/runner-images/blob/ubuntu20/20230924.1/images/linux/Ubuntu2004-Readme.md#nodejs - version: '${{ variables.nodeVersion }}.x' + version: ${{ variables.nodeVersion }}.x checkLatest: false # For multiline scripts, we want the whole task to fail if any line of the script fails. @@ -92,14 +92,19 @@ extends: displayName: Show environment - script: node scripts/preparePublishRegistry.ts - displayName: Prepare npm feed + displayName: Prepare npm registry settings - task: npmAuthenticate@0 displayName: npm authenticate inputs: - workingFile: '$(Build.SourcesDirectory)/.npmrc' + workingFile: $(Build.SourcesDirectory)/.npmrc + + # This isn't used, it's just a clear way to check that auth is working + # (yarn install's auth errors can be very unclear) + - script: yarn npm info beachball + displayName: Get package to verify registry auth - - script: yarn --frozen-lockfile + - script: yarn --immutable displayName: Install dependencies - script: yarn build --verbose @@ -110,12 +115,17 @@ extends: # TODO: figure out if this should bump or not (currently does bump) # and currently uses a prerelease prefix of 'test' to avoid interfering with real versions + # TODO: update beachball to read the registry from .npmrc and/or .yarnrc.yml # TODO (release): change back to: - # yarn beachball publish --prerelease-prefix test -y --no-push --pack-to-path '$(Build.StagingDirectory)/pkg' + # yarn beachball publish --prerelease-prefix test -y --no-push --pack-to-path '$(Build.StagingDirectory)/packed-packages' - script: | - yarn release:canary --no-push --pack-to-path '$(Build.StagingDirectory)/pkg' - displayName: 'Pack packages (canary)' + yarn release:canary --no-push --verbose --pack-to-path '$(Build.StagingDirectory)/out/packed-packages' --registry https://pkgs.dev.azure.com/office/_packaging/Office/npm/registry/ + displayName: Pack packages (canary) - script: | - ls -R '$(Build.StagingDirectory)/pkg' - displayName: Show all files in pack directory + mkdir -p $(Build.StagingDirectory)/out/release-api-tool + cp -r packages/esrp-npm-release/dist/index.js $(Build.StagingDirectory)/out/release-api-tool + displayName: Copy release API tool to staging directory + + - script: ls -R '$(Build.StagingDirectory)/out' + displayName: Show all output files diff --git a/.ado/release.yml b/.ado/release.yml index 5b6b0634a..4a12c40c8 100644 --- a/.ado/release.yml +++ b/.ado/release.yml @@ -9,9 +9,10 @@ trigger: none resources: pipelines: - - pipeline: 'beachball_publish' - project: 'ISS' - source: 'beachball - prepublish' + # must match publishPipelineAlias variable + - pipeline: prepublish + project: ISS + source: beachball - prepublish trigger: branches: include: @@ -24,6 +25,12 @@ resources: name: 1ESPipelineTemplates/OfficePipelineTemplates ref: refs/tags/release +variables: + nodeVersion: 24 + publishPipelineAlias: prepublish + packagesArtifactName: packed-packages + releaseApiToolArtifactName: release-api-tool + extends: template: v1/Office.Official.PipelineTemplate.yml@OfficePipelineTemplates parameters: @@ -36,36 +43,75 @@ extends: - stage: main_release displayName: Publish packages jobs: - - job: npmjs_com_job + - job: npm_release displayName: NPM to npmjs.com templateContext: type: releaseJob isProduction: true inputs: - input: pipelineArtifact - pipeline: beachball_publish - artifactName: published-packages - targetPath: $(Pipeline.Workspace)\published-packages + pipeline: ${{ variables.publishPipelineAlias }} + artifactName: ${{ variables.packagesArtifactName }} + targetPath: $(Agent.BuildDirectory)\${{ variables.packagesArtifactName }} + - input: pipelineArtifact + pipeline: ${{ variables.publishPipelineAlias }} + artifactName: ${{ variables.releaseApiToolArtifactName }} + targetPath: $(Agent.BuildDirectory)\${{ variables.releaseApiToolArtifactName }} # Use ESRP Release to securely publish to npmjs.com. steps: - - script: dir /S $(Pipeline.Workspace)\published-packages + - task: UseNode@1 + displayName: Install Node.js ${{ variables.nodeVersion }} + retryCountOnTaskFailure: 1 + inputs: + version: ${{ variables.nodeVersion }}.x + checkLatest: false + + - script: dir /S $(Agent.BuildDirectory)\${{ variables.packagesArtifactName }} displayName: Show directory contents - - task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@9' - displayName: 'ESRP Release to npmjs.com' + # Get credentials that will be used to temporarily upload zips to ogxesrptempstorage + # in the OGX azure subscription + - task: AzureCLI@2 + displayName: Get credentials for staging blob storage inputs: - connectedservicename: 'ESRP-JSHost3' - usemanagedidentity: false - keyvaultname: 'OGX-JSHost-KV' - authcertname: 'OGX-JSHost-Auth4' - signcertname: 'OGX-JSHost-Sign3' - clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d' - domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' - contenttype: npm - folderlocation: $(Pipeline.Workspace)\published-packages - owners: 'elcraig@microsoft.com' - approvers: 'dannyvv@microsoft.com' - # npm tag - # potentially can also be inferred from the publishConfig field within the package.json file. - productstate: 'test' + # this is the service connection name + azureSubscription: ogx-esrp-infra-bot + scriptType: pscore + scriptLocation: inlineScript + addSpnToEnvironment: true + inlineScript: | + Write-Host "##vso[task.setvariable variable=STAGING_TENANT_ID]$env:tenantId" + Write-Host "##vso[task.setvariable variable=STAGING_CLIENT_ID]$env:servicePrincipalId" + Write-Host "##vso[task.setvariable variable=STAGING_ID_TOKEN;issecret=true]$env:idToken" + + # Fetch ESRP certificates from key vault in Torus + - task: AzureKeyVault@2 + displayName: Get ESRP certificates from Key Vault + inputs: + # torus service connection name ("connectedservicename" from EsrpRelease task) + azureSubscription: ESRP-JSHost3 + # "keyvaultname" from EsrpRelease task + KeyVaultName: OGX-JSHost-KV + # "authcertname" and "signcertname" from EsrpRelease task + SecretsFilter: OGX-JSHost-Auth4,OGX-JSHost-Sign3 + + - script: node $(Agent.BuildDirectory)\${{ variables.releaseApiToolArtifactName }}\index.js + displayName: Publish to npmjs.com using ESRP Release API wrapper + retryCountOnTaskFailure: 3 + env: + PACKED_PACKAGES_PATH: $(Agent.BuildDirectory)\${{ variables.packagesArtifactName }} + ESRP_PRODUCT_NAME: beachball + ESRP_NPM_TAG: test + ESRP_USER: elcraig@microsoft.com + ESRP_APPROVERS: dannyvv@microsoft.com + # torus tenant ID ("domaintenantid" from EsrpRelease task, same as service connection "ESRP-JSHost3") + ESRP_TENANT_ID: cdc5aeea-15c5-4db6-b079-fcadd2505dc2 + # "clientid" from EsrpRelease task + ESRP_CLIENT_ID: 0a35e01f-eadf-420a-a2bf-def002ba898d + ESRP_AUTH_CERT: $(OGX-JSHost-Auth4) + ESRP_REQUEST_SIGNING_CERT: $(OGX-JSHost-Sign3) + STAGING_STORAGE_ACCOUNT_NAME: ogxesrptempstorage + STAGING_CLIENT_ID: $(STAGING_CLIENT_ID) + STAGING_TENANT_ID: $(STAGING_TENANT_ID) + STAGING_ID_TOKEN: $(STAGING_ID_TOKEN) diff --git a/.ado/roleAssignments.bicep b/.ado/roleAssignments.bicep new file mode 100644 index 000000000..f7b43be7a --- /dev/null +++ b/.ado/roleAssignments.bicep @@ -0,0 +1,124 @@ +/* +Apply changes: + az deployment group create \ + --subscription "" \ + --resource-group "" \ + --template-file .ado/roleAssignments.bicep \ + --parameters \ + stagingStorageName= \ + managedIdentityName= + +Preview changes: + az deployment group what-if ... +*/ + +// Name of the user-assigned managed identity to create and grant storage roles to. +param managedIdentityName string + +param stagingStorageName string + +// Built-in role definition IDs. +// See https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles +var roleDefinitions = { + storageBlobDataContributor: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + storageBlobDelegator: 'db58b8e5-c6ad-4a2a-8342-4190687cbf4a' +} + +// If an account with this name already exists in the resource group, the deployment reconciles +// its properties to match the values below — make sure they match the existing account, or run +// `what-if` first to preview. +resource stagingStorage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: stagingStorageName + location: resourceGroup().location + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + properties: { + accessTier: 'Hot' + allowBlobPublicAccess: false + allowSharedKeyAccess: false + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + } +} + +// User-assigned managed identity that will be granted storage roles below. +// UAMIs are service principals as far as role assignments are concerned. +resource uami 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: managedIdentityName + location: resourceGroup().location +} + +resource blobDataContrib 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: stagingStorage + name: guid(stagingStorage.id, managedIdentityName, 'StorageBlobDataContributor') + properties: { + principalId: uami.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + roleDefinitions.storageBlobDataContributor + ) + } +} + +resource blobDelegator 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: stagingStorage + name: guid(stagingStorage.id, managedIdentityName, 'StorageBlobDelegator') + properties: { + principalId: uami.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + roleDefinitions.storageBlobDelegator + ) + } +} + +// Garbage-collect staged release artifacts and release-state markers. +// The tool deletes staging blobs in a finally block after each release, but stragglers +// can be left behind if the process is killed mid-release. release-state markers are +// never deleted by the tool and would otherwise accumulate forever. +resource lifecyclePolicy 'Microsoft.Storage/storageAccounts/managementPolicies@2023-05-01' = { + parent: stagingStorage + name: 'default' + properties: { + policy: { + rules: [ + { + name: 'expire-staging-blobs' + enabled: true + type: 'Lifecycle' + definition: { + filters: { + blobTypes: ['blockBlob'] + prefixMatch: ['staging/'] + } + actions: { + baseBlob: { + delete: { daysAfterModificationGreaterThan: 3 } + } + } + } + } + { + name: 'expire-release-state' + enabled: true + type: 'Lifecycle' + definition: { + filters: { + blobTypes: ['blockBlob'] + prefixMatch: ['release-state/'] + } + actions: { + baseBlob: { + delete: { daysAfterModificationGreaterThan: 90 } + } + } + } + } + ] + } + } +} diff --git a/.gitignore b/.gitignore index 9c80fd59c..dd3e1fa39 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ docs/.yarn .yarn/* !.yarn/patches/ +!.yarn/plugins/ !.yarn/releases/ # Ignore this in case a local .npmrc is added with a token for publishing .npmrc diff --git a/.prettierignore b/.prettierignore index 35d4d8d08..30da1c914 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ .yarn/ +*.bicep *.log *.patch *.snap diff --git a/.yarn/patches/jws-npm-4.0.1-0d8c257cbe.patch b/.yarn/patches/jws-npm-4.0.1-0d8c257cbe.patch new file mode 100644 index 000000000..e2c5a722c --- /dev/null +++ b/.yarn/patches/jws-npm-4.0.1-0d8c257cbe.patch @@ -0,0 +1,19 @@ +diff --git a/lib/tostring.js b/lib/tostring.js +index f5a49a36548b1e299042c9e3d1cdd60c71d8ec0c..493dd5cdd98ad99f84fb740864f9dd54c73ab7a3 100644 +--- a/lib/tostring.js ++++ b/lib/tostring.js +@@ -4,7 +4,11 @@ var Buffer = require('buffer').Buffer; + module.exports = function toString(obj) { + if (typeof obj === 'string') + return obj; +- if (typeof obj === 'number' || Buffer.isBuffer(obj)) ++ if (typeof obj === 'number' || typeof obj === 'bigint' || Buffer.isBuffer(obj)) + return obj.toString(); +- return JSON.stringify(obj); ++ const marker = '__BIGINT_' + Math.random().toString(36).slice(2) + '__'; ++ const json = JSON.stringify(obj, (_k, v) => ++ typeof v === 'bigint' ? marker + v.toString() + marker : v ++ ); ++ return json.replace(new RegExp('"' + marker + '(-?\\d+)' + marker + '"', 'g'), '$1'); + }; +\ No newline at end of file diff --git a/.yarn/plugins/@yarnpkg/plugin-npmrc.cjs b/.yarn/plugins/@yarnpkg/plugin-npmrc.cjs new file mode 100644 index 000000000..f247e80dd --- /dev/null +++ b/.yarn/plugins/@yarnpkg/plugin-npmrc.cjs @@ -0,0 +1,13 @@ +/* eslint-disable */ +//prettier-ignore +module.exports = { +name: "@yarnpkg/plugin-npmrc", +factory: function (require) { +"use strict";var plugin=(()=>{var Dt=Object.create;var D=Object.defineProperty;var Mt=Object.getOwnPropertyDescriptor;var Bt=Object.getOwnPropertyNames;var Ht=Object.getPrototypeOf,It=Object.prototype.hasOwnProperty;var E=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,r)=>(typeof require<"u"?require:t)[r]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});var Q=(e,t)=>()=>(e&&(t=e(e=0)),t);var O=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),z=(e,t)=>{for(var r in t)D(e,r,{get:t[r],enumerable:!0})},ve=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of Bt(t))!It.call(e,i)&&i!==r&&D(e,i,{get:()=>t[i],enumerable:!(n=Mt(t,i))||n.enumerable});return e};var Z=(e,t,r)=>(r=e!=null?Dt(Ht(e)):{},ve(t||!e||!e.__esModule?D(r,"default",{value:e,enumerable:!0}):r,e)),Ft=e=>ve(D({},"__esModule",{value:!0}),e);var Ne=O((Vr,Se)=>{var{hasOwnProperty:ee}=Object.prototype,te=(e,t={})=>{typeof t=="string"&&(t={section:t}),t.align=t.align===!0,t.newline=t.newline===!0,t.sort=t.sort===!0,t.whitespace=t.whitespace===!0||t.align===!0,t.platform=t.platform||typeof process<"u"&&process.platform,t.bracketedArray=t.bracketedArray!==!1;let r=t.platform==="win32"?`\r +`:` +`,n=t.whitespace?" = ":"=",i=[],o=t.sort?Object.keys(e).sort():Object.keys(e),f=0;t.align&&(f=_(o.filter(s=>e[s]===null||Array.isArray(e[s])||typeof e[s]!="object").map(s=>Array.isArray(e[s])?`${s}[]`:s).concat([""]).reduce((s,l)=>_(s).length>=_(l).length?s:l)).length);let c="",a=t.bracketedArray?"[]":"";for(let s of o){let l=e[s];if(l&&Array.isArray(l))for(let p of l)c+=_(`${s}${a}`).padEnd(f," ")+n+_(p)+r;else l&&typeof l=="object"?i.push(s):c+=_(s).padEnd(f," ")+n+_(l)+r}t.section&&c.length&&(c="["+_(t.section)+"]"+(t.newline?r+r:r)+c);for(let s of i){let l=Ae(s,".").join("\\."),p=(t.section?t.section+".":"")+l,d=te(e[s],{...t,section:p});c.length&&d.length&&(c+=r),c+=d}return c};function Ae(e,t){var r=0,n=0,i=0,o=[];do if(i=e.indexOf(t,r),i!==-1){if(r=i+t.length,i>0&&e[i-1]==="\\")continue;o.push(e.slice(n,i)),n=i+t.length}while(i!==-1);return o.push(e.slice(n)),o}var Oe=(e,t={})=>{t.bracketedArray=t.bracketedArray!==!1;let r=Object.create(null),n=r,i=null,o=/^\[([^\]]*)\]\s*$|^([^=]+)(=(.*))?$/i,f=e.split(/[\r\n]+/g),c={};for(let s of f){if(!s||s.match(/^\s*[;#]/)||s.match(/^\s*$/))continue;let l=s.match(o);if(!l)continue;if(l[1]!==void 0){if(i=M(l[1]),i==="__proto__"){n=Object.create(null);continue}n=r[i]=r[i]||Object.create(null);continue}let p=M(l[2]),d;t.bracketedArray?d=p.length>2&&p.slice(-2)==="[]":(c[p]=(c?.[p]||0)+1,d=c[p]>1);let u=d&&p.endsWith("[]")?p.slice(0,-2):p;if(u==="__proto__")continue;let y=l[3]?M(l[4]):!0,b=y==="true"||y==="false"||y==="null"?JSON.parse(y):y;d&&(ee.call(n,u)?Array.isArray(n[u])||(n[u]=[n[u]]):n[u]=[]),Array.isArray(n[u])?n[u].push(b):n[u]=b}let a=[];for(let s of Object.keys(r)){if(!ee.call(r,s)||typeof r[s]!="object"||Array.isArray(r[s]))continue;let l=Ae(s,".");n=r;let p=l.pop(),d=p.replace(/\\\./g,".");for(let u of l)u!=="__proto__"&&((!ee.call(n,u)||typeof n[u]!="object")&&(n[u]=Object.create(null)),n=n[u]);n===r&&d===p||(n[d]=r[s],a.push(s))}for(let s of a)delete r[s];return r},Pe=e=>e.startsWith('"')&&e.endsWith('"')||e.startsWith("'")&&e.endsWith("'"),_=e=>typeof e!="string"||e.match(/[=\r\n]/)||e.match(/^\[/)||e.length>1&&Pe(e)||e!==e.trim()?JSON.stringify(e):e.split(";").join("\\;").split("#").join("\\#"),M=e=>{if(e=(e||"").trim(),Pe(e)){e.charAt(0)==="'"&&(e=e.slice(1,-1));try{e=JSON.parse(e)}catch{}}else{let t=!1,r="";for(let n=0,i=e.length;n{$e.exports=Gt;function Gt(...e){let t=e;e.length===1&&(Array.isArray(e[0])||typeof e[0]=="string")&&(t=[].concat(e[0]));for(let i=0,o=t.length;it?1:-1}});var re=O((Xr,Te)=>{Te.exports=process.env.DEBUG_NOPT||process.env.NOPT_DEBUG?(...e)=>console.error(...e):()=>{}});var ie=O((Kr,Re)=>{var Ce=E("url"),ne=E("path"),ke=E("stream").Stream,Ut=E("os"),qe=re();function Vt(e,t,r){e[t]=String(r)}function Jt(e,t,r){if(r===!0)return!1;if(r===null)return!0;r=String(r);let i=process.platform==="win32"?/^~(\/|\\)/:/^~\//,o=Ut.homedir();return o&&r.match(i)?e[t]=ne.resolve(o,r.slice(2)):e[t]=ne.resolve(r),!0}function Xt(e,t,r){if(qe("validate Number %j %j %j",t,r,isNaN(r)),isNaN(r))return!1;e[t]=+r}function Kt(e,t,r){let n=Date.parse(r);if(qe("validate Date %j %j %j",t,r,n),isNaN(n))return!1;e[t]=new Date(r)}function Yt(e,t,r){typeof r=="string"?isNaN(r)?r==="null"||r==="false"?r=!1:r=!0:r=!!+r:r=!!r,e[t]=r}function Qt(e,t,r){if(r=Ce.parse(String(r)),!r.host)return!1;e[t]=r.href}function zt(e,t,r){if(!(r instanceof ke))return!1;e[t]=r}Re.exports={String:{type:String,validate:Vt},Boolean:{type:Boolean,validate:Yt},url:{type:Ce,validate:Qt},Number:{type:Number,validate:Xt},path:{type:ne,validate:Jt},Stream:{type:ke,validate:zt},Date:{type:Date,validate:Kt},Array:{type:Array}}});var Ge=O((Yr,Fe)=>{var B=_e(),w=re(),Zt=ie(),De=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),Me=(e,{types:t,dynamicTypes:r})=>{let n=De(t,e),i=t[e];if(!n&&typeof r=="function"){let o=r(e);o!==void 0&&(i=o,n=!0)}return[n,i]},k=(e,t)=>t&&e===t,$=(e,t)=>t&&e.indexOf(t)!==-1,er=(e,t)=>t&&!$(e,t);function tr(e,{types:t,shorthands:r,typeDefs:n,invalidHandler:i,unknownHandler:o,abbrevHandler:f,typeDefault:c,dynamicTypes:a}={}){w(t,r,e,n);let s={},l={remain:[],cooked:e,original:e.slice(0)};return He(e,s,l.remain,{typeDefs:n,types:t,dynamicTypes:a,shorthands:r,unknownHandler:o,abbrevHandler:f}),Be(s,{types:t,dynamicTypes:a,typeDefs:n,invalidHandler:i,typeDefault:c}),s.argv=l,Object.defineProperty(s.argv,"toString",{value:function(){return this.original.map(JSON.stringify).join(" ")},enumerable:!1}),s}function Be(e,{types:t={},typeDefs:r={},dynamicTypes:n,invalidHandler:i,typeDefault:o}={}){let f=r.String?.type,c=r.Number?.type,a=r.Array?.type,s=r.Boolean?.type,l=r.Date?.type,p=typeof o<"u";p||(o=[!1,!0,null],f&&o.push(f),a&&o.push(a));let d={};Object.keys(e).forEach(u=>{if(u==="argv")return;let y=e[u];w("val=%j",y);let b=Array.isArray(y),[h,A]=Me(u,{types:t,dynamicTypes:n}),x=A;b||(y=[y]),x||(x=o),k(x,a)&&(x=o.concat(a)),Array.isArray(x)||(x=[x]),w("val=%j",y),w("types=",x),y=y.map(g=>{if(typeof g=="string"&&(w("string %j",g),g=g.trim(),g==="null"&&~x.indexOf(null)||g==="true"&&(~x.indexOf(!0)||$(x,s))||g==="false"&&(~x.indexOf(!1)||$(x,s))?(g=JSON.parse(g),w("jsonable %j",g)):$(x,c)&&!isNaN(g)?(w("convert to number",g),g=+g):$(x,l)&&!isNaN(Date.parse(g))&&(w("convert to date",g),g=new Date(g))),!h){if(!p)return g;A=o}g===!1&&~x.indexOf(null)&&!(~x.indexOf(!1)||$(x,s))&&(g=null);let N={};return N[u]=g,w("prevalidated val",N,g,A),oe(N,u,g,A,{typeDefs:r})?(w("validated v",N,g,A),N[u]):(i?i(u,g,A,e):i!==!1&&w("invalid: "+u+"="+g,A),d)}).filter(g=>g!==d),!y.length&&er(x,a)?(w("VAL HAS NO LENGTH, DELETE IT",y,u,x.indexOf(a)),delete e[u]):b?(w(b,e[u],y),e[u]=y):e[u]=y[0],w("k=%s val=%j",u,y,e[u])})}function oe(e,t,r,n,{typeDefs:i}={}){let o=i?.Array?.type;if(Array.isArray(n)){for(let a=0,s=n.length;a1){let x=h.indexOf("=");if(x>-1){A=!0;let L=h.slice(x+1);h=h.slice(0,x),e.splice(b,1,h,L)}let g=Ie(h,y,u,{shorthands:o,abbrevHandler:a});if(w("arg=%j shRes=%j",h,g),g&&(e.splice.apply(e,[b,1].concat(g)),h!==g[0])){b--;continue}h=h.replace(/^-+/,"");let N=null;for(;h.toLowerCase().indexOf("no-")===0;)N=!N,h=h.slice(3);u[h]&&u[h]!==h&&(a?a(h,u[h]):a!==!1&&w(`abbrev: ${h} -> ${u[h]}`),h=u[h]);let[Rt,v]=Me(h,{types:n,dynamicTypes:f}),C=Array.isArray(v);C&&v.length===1&&(C=!1,v=v[0]);let Y=k(v,p)||C&&$(v,p);!Rt&&De(t,h)&&(Array.isArray(t[h])||(t[h]=[t[h]]),Y=!0);let P,m=e[b+1],Lt=typeof N=="boolean"||k(v,d)||C&&$(v,d)||typeof v>"u"&&!A||m==="false"&&(v===null||C&&~v.indexOf(null));if(typeof v>"u"){let L=!A&&m&&!m?.startsWith("-")&&!["true","false"].includes(m);c?L?c(h,m):c(h):c!==!1&&(w(`unknown: ${h}`),L&&w(`unknown: ${m} parsed as normal opt`))}if(Lt){P=!N,(m==="true"||m==="false")&&(P=JSON.parse(m),m=null,N&&(P=!P),b++),C&&m&&(~v.indexOf(m)?(P=m,b++):m==="null"&&~v.indexOf(null)?(P=null,b++):!m.match(/^-{2,}[^-]/)&&!isNaN(m)&&$(v,l)?(P=+m,b++):!m.match(/^-[^-]/)&&$(v,s)&&(P=m,b++)),Y?(t[h]=t[h]||[]).push(P):t[h]=P;continue}k(v,s)&&(m===void 0?m="":m.match(/^-{1,2}[^-]+/)&&(m="",b--)),m&&m.match(/^-{2,}$/)&&(m=void 0,b--),P=m===void 0?!0:m,Y?(t[h]=t[h]||[]).push(P):t[h]=P,b++;continue}r.push(h)}}var Le=Symbol("singles"),rr=(e,t)=>{let r=t[Le];r||(r=Object.keys(t).filter(i=>i.length===1).reduce((i,o)=>(i[o]=!0,i),{}),t[Le]=r,w("shorthand singles",r));let n=e.split("").filter(i=>r[i]);return n.join("")===e?n:null};function Ie(e,...t){let{abbrevHandler:r,types:n={},shorthands:i={}}=t.length?t.pop():{},o=t[0]??B(Object.keys(i)),f=t[1]??B(Object.keys(n));if(e=e.replace(/^-+/,""),f[e]===e)return null;if(i[e])return i[e]&&!Array.isArray(i[e])&&(i[e]=i[e].split(/\s+/)),i[e];let c=rr(e,i);return c?c.map(a=>i[a]).reduce((a,s)=>a.concat(s),[]):f[e]&&!i[e]?null:(o[e]&&(r?r(e,o[e]):r!==!1&&w(`abbrev: ${e} -> ${o[e]}`),e=o[e]),i[e]&&!Array.isArray(i[e])&&(i[e]=i[e].split(/\s+/)),i[e])}Fe.exports={nopt:tr,clean:Be,parse:He,validate:oe,resolveShort:Ie,typeDefs:Zt}});var ae=O((S,We)=>{var se=Ge(),nr=ie();We.exports=S=ir;S.clean=or;S.typeDefs=nr;S.lib=se;function ir(e,t,r=process.argv,n=2){return se.nopt(r.slice(n),{types:e||{},shorthands:t||{},typeDefs:S.typeDefs,invalidHandler:S.invalidHandler,unknownHandler:S.unknownHandler,abbrevHandler:S.abbrevHandler})}function or(e,t,r=S.typeDefs){return se.clean(e,{types:t||{},typeDefs:r,invalidHandler:S.invalidHandler,unknownHandler:S.unknownHandler,abbrevHandler:S.abbrevHandler})}});var Ve=O((Qr,Ue)=>{var sr=Symbol("proc-log.meta");Ue.exports={META:sr,output:{LEVELS:["standard","error","buffer","flush"],KEYS:{standard:"standard",error:"error",buffer:"buffer",flush:"flush"},standard:function(...e){return process.emit("output","standard",...e)},error:function(...e){return process.emit("output","error",...e)},buffer:function(...e){return process.emit("output","buffer",...e)},flush:function(...e){return process.emit("output","flush",...e)}},log:{LEVELS:["notice","error","warn","info","verbose","http","silly","timing","pause","resume"],KEYS:{notice:"notice",error:"error",warn:"warn",info:"info",verbose:"verbose",http:"http",silly:"silly",timing:"timing",pause:"pause",resume:"resume"},error:function(...e){return process.emit("log","error",...e)},notice:function(...e){return process.emit("log","notice",...e)},warn:function(...e){return process.emit("log","warn",...e)},info:function(...e){return process.emit("log","info",...e)},verbose:function(...e){return process.emit("log","verbose",...e)},http:function(...e){return process.emit("log","http",...e)},silly:function(...e){return process.emit("log","silly",...e)},timing:function(...e){return process.emit("log","timing",...e)},pause:function(){return process.emit("log","pause")},resume:function(){return process.emit("log","resume")}},time:{LEVELS:["start","end"],KEYS:{start:"start",end:"end"},start:function(e,t){process.emit("time","start",e);function r(){return process.emit("time","end",e)}if(typeof t=="function"){let n=t();return n&&n.finally?n.finally(r):(r(),n)}return r},end:function(e){return process.emit("time","end",e)}},input:{LEVELS:["start","end","read"],KEYS:{start:"start",end:"end",read:"read"},start:function(...e){let t;typeof e[0]=="function"&&(t=e.shift()),process.emit("input","start",...e);function r(){return process.emit("input","end",...e)}if(typeof t=="function"){let n=t();return n&&n.finally?n.finally(r):(r(),n)}return r},end:function(...e){return process.emit("input","end",...e)},read:function(...e){let t,r,n=new Promise((i,o)=>{t=i,r=o});return process.emit("input","read",t,r,...e),n}}}});var I=O((zr,ce)=>{var H=ae(),ar=H.typeDefs.path.validate,cr=(e,t,r)=>typeof r!="string"?!1:ar(e,t,r);ce.exports={...H.typeDefs,path:{...H.typeDefs.path,validate:cr}};H.typeDefs=ce.exports});var Ke=O((Zr,Xe)=>{var{URL:Je}=E("url");Xe.exports=e=>{let t=new Je(e),r=`${t.protocol}//${t.host}${t.pathname}`,n=new Je(".",r);return`//${n.host}${n.pathname}`}});var le=O((en,Ye)=>{var lr=/(?e.replace(lr,(r,n,i,o)=>{let f=o==="?"?"":`\${${i}}`,c=t[i]!==void 0?t[i]:f;return n.length%2?r.slice((n.length+1)/2):n.slice(n.length/2)+c})});var Ze=O((tn,ze)=>{var F=I(),fr=le(),{resolve:Qe}=E("path"),fe=(e,t,r,n=!1)=>{if(typeof e!="string"&&!Array.isArray(e))return e;let{platform:i,types:o,home:f,env:c}=r,a=new Set([].concat(o[t])),s=a.has(F.path.type),l=a.has(F.Boolean.type),p=s||a.has(F.String.type),d=a.has(F.Number.type),u=!n&&a.has(Array);if(Array.isArray(e))return u?e.map(y=>fe(y,t,r,!0)):e;if(e=e.trim(),u)return fe(e.split(` + +`),t,r);if(l&&!p&&e==="")return!0;if(!p&&!s&&!d)switch(e){case"true":return!0;case"false":return!1;case"null":return null;case"undefined":return}return e=fr(e,c),s&&((i==="win32"?/^~(\/|\\)/:/^~\//).test(e)&&f?e=Qe(f,e.slice(2)):e=Qe(e)),d&&!isNaN(e)&&(e=+e),e};ze.exports=fe});var tt=O((rn,et)=>{var T=class{constructor(t,r){this.key=t,this.type=r.type,this.default=r.default}},{url:{type:ur},path:{type:ue}}=I(),pr={_auth:new T("_auth",{default:null,type:[null,String]}),global:new T("global",{default:!1,type:Boolean}),globalconfig:new T("globalconfig",{type:ue,default:""}),location:new T("location",{default:"user",type:["global","user","project"]}),prefix:new T("prefix",{type:ue,default:""}),registry:new T("registry",{default:"https://registry.npmjs.org/",type:ur}),userconfig:new T("userconfig",{default:"~/.npmrc",type:ue})};et.exports=pr});var it=O((nn,nt)=>{var rt=tt(),hr=(e,t={})=>{for(let[r,n]of Object.entries(e)){let i=rt[r];i&&i.flatten?i.flatten(r,e,t):(/@.*:registry$/i.test(r)||/^\/\//.test(r))&&(t[r]=n)}return t};nt.exports={definitions:rt,flatten:hr}});var st=O((on,ot)=>{"use strict";var pe=class extends Error{constructor(t){let r="Invalid auth configuration found: ";r+=t.map(n=>{if(n.action==="delete")return`\`${n.key}\` is not allowed in ${n.where} config`;if(n.action==="rename")return`\`${n.from}\` must be renamed to \`${n.to}\` in ${n.where} config`}).join(", "),r+="\nPlease run `npm config fix` to repair your configuration.`",super(r),this.code="ERR_INVALID_AUTH",this.problems=t}};ot.exports={ErrInvalidAuth:pe}});var ft=O((sn,lt)=>{var dr=Ne(),he=ae(),{log:q}=Ve(),{resolve:W,dirname:de,join:at}=E("path"),{homedir:gr}=E("os"),{readFile:yr,stat:mr}=E("fs/promises"),wr=(...e)=>mr(W(...e)).then(t=>t.isFile()).catch(()=>!1),ge=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),br=I(),ct=Ke(),xr=le(),Er=Ze(),{definitions:jr}=it(),vr=new Set(["global","user","project"]),ye=new Set(["default","builtin",...vr,"env"]),Or="env",me=class{#n=!1;#r="";#e="";constructor({npmPath:t,projectRoot:r,workspaceRoot:n,env:i=process.env,platform:o=process.platform,execPath:f=process.execPath,cwd:c=process.cwd()}){if(!r||!n)throw new Error("must provide projectRoot and workspaceRoot options");this.#r=r,this.#e=n;let a={},s={};for(let[d,u]of Object.entries(jr))s[d]=u.default,a[d]=u.type;this.types=a,this.defaults=s,this.npmPath=t,this.env=i,this.execPath=f,this.platform=o,this.cwd=c,this.globalPrefix=null,this.localPrefix=null,this.home=null;let l=[...ye];this.data=new Map;let p=null;for(let d of l)this.data.set(d,p=new we(p));this.data.set=()=>{throw new Error("cannot change internal config data structure")},this.data.delete=()=>{throw new Error("cannot change internal config data structure")},this.sources=new Map([]);for(let{data:d}of this.data.values())this.list.unshift(d);this.#n=!1}get list(){let t=[];for(let{data:r}of this.data.values())t.unshift(r);return t}get loaded(){return this.#n}get prefix(){return this.#t("global")?this.globalPrefix:this.localPrefix}get(t,r){if(!this.loaded)throw new Error("call config.load() before reading values");return this.#t(t,r)}#t(t,r=null){if(r!==null&&!ye.has(r))throw new Error("invalid config location param: "+r);let{data:n}=this.data.get(r||Or);return r===null||ge(n,t)?n[t]:void 0}async load(){if(this.loaded)throw new Error("attempting to load npm config multiple times");this.loadDefaults(),await this.loadBuiltinConfig(),this.loadEnv(),await this.loadProjectConfig(),await this.loadUserConfig(),await this.loadGlobalConfig(),this.#n=!0,this.globalPrefix=this.get("prefix")}loadDefaults(){this.loadGlobalPrefix(),this.loadHome();let t={...this.defaults,prefix:this.globalPrefix};try{t["npm-version"]=E(at(this.npmPath,"package.json")).version}catch{}this.#i(t,"default","default values");let{data:r}=this.data.get("default");Object.defineProperty(r,"globalconfig",{get:()=>W(this.#t("prefix"),"etc/npmrc"),set(n){Object.defineProperty(r,"globalconfig",{value:n,configurable:!0,writable:!0,enumerable:!0})},configurable:!0,enumerable:!0})}loadHome(){this.home=this.env.HOME||gr()}loadGlobalPrefix(){if(this.globalPrefix)throw new Error("cannot load default global prefix more than once");this.env.PREFIX?this.globalPrefix=this.env.PREFIX:this.platform==="win32"?this.globalPrefix=de(this.execPath):(this.globalPrefix=de(de(this.execPath)),this.env.DESTDIR&&(this.globalPrefix=at(this.env.DESTDIR,this.globalPrefix)))}loadEnv(){let t=Object.create(null);for(let[r,n]of Object.entries(this.env)){if(!/^npm_config_/i.test(r)||n==="")continue;let i=r.slice(11);i.startsWith("//")||(i=i.replace(/(?!^)_/g,"-").toLowerCase()),t[i]=n}this.#i(t,"env","environment")}get valid(){for(let[t,{valid:r}]of this.data.entries())if(r===!1||r===null&&!this.validate(t))return!1;return!0}validate(t){if(t){let r=this.data.get(t);return r[R]=!0,he.invalidHandler=(n,i,o)=>this.invalidHandler(n,i,o,r.source,t),he.clean(r.data,this.types,br),he.invalidHandler=null,r[R]}else{let r=!0,n=[];for(let i of this.data.keys()){if(i==="default"||i==="builtin")continue;let o=this.validate(i);if(r=r&&o,["global","user","project"].includes(i)){for(let c of["_authtoken","-authtoken"])this.get(c,i)&&n.push({action:"delete",key:c,where:i});let f=ct(this.get("registry"));for(let c of["_auth","_authToken","username","_password"])this.get(c,i)&&(c==="username"&&!this.get("_password",i)?n.push({action:"delete",key:c,where:i}):c==="_password"&&!this.get("username",i)?n.push({action:"delete",key:c,where:i}):n.push({action:"rename",from:c,to:`${f}:${c}`,where:i}))}}if(n.length){let{ErrInvalidAuth:i}=st();throw new i(n)}return r}}isDefault(t){let[r,...n]=[...ye],i=this.data.get(r).data;return ge(i,t)&&n.every(o=>{let f=this.data.get(o).data;return!ge(f,t)})}invalidHandler(t,r,n,i,o){q.warn("invalid config",t+"="+JSON.stringify(r),`set in ${i}`),this.data.get(o)[R]=!1}#i(t,r,n,i=null){let o=this.data.get(r);if(o.source){let f=`double-loading "${r}" configs from ${n}, previously loaded from ${o.source}`;throw new Error(f)}if(this.sources.has(n)){let f=`double-loading config "${n}" as "${r}", previously loaded as "${this.sources.get(n)}"`;throw new Error(f)}if(o.source=n,this.sources.set(n,r),i)o.loadError=i,i.code!=="ENOENT"&&q.verbose("config",`error loading ${r} config`,i);else{o.raw=t;for(let[f,c]of Object.entries(t)){let a=xr(f,this.env),s=this.parseField(c,a);o.data[a]=s}}}parseField(t,r,n=!1){return Er(t,r,this,n)}async#o(t,r){q.silly("config",`load:file:${t}`),await yr(t,"utf8").then(n=>{let i=dr.parse(n);return r==="project"&&i.prefix&&q.error("config",`prefix cannot be changed from project config: ${t}.`),this.#i(i,r,t)},n=>this.#i(null,r,t,n))}loadBuiltinConfig(){return this.#o(W(this.npmPath,"npmrc"),"builtin")}async loadProjectConfig(){if(await this.loadLocalPrefix(),this.#t("global")===!0||this.#t("location")==="global"){this.data.get("project").source="(global mode enabled, ignored)",this.sources.set(this.data.get("project").source,"project");return}let t=W(this.localPrefix,".npmrc");if(t!==this.#t("userconfig"))return this.#o(t,"project");this.data.get("project").source='(same as "user" config, ignored)',this.sources.set(this.data.get("project").source,"project")}async loadLocalPrefix(){this.#t("global")||this.#t("location")==="global"?this.localPrefix=this.#e:(this.#r!==this.#e&&await wr(this.#e,".npmrc")&&q.warn("config",`ignoring workspace config at ${this.#e}/.npmrc`),this.localPrefix=this.#r)}loadUserConfig(){return this.#o(this.#t("userconfig"),"user")}loadGlobalConfig(){return this.#o(this.#t("globalconfig"),"global")}getCredentialsByURI(t){let r=ct(t),n={},i=this.get(`${r}:certfile`),o=this.get(`${r}:keyfile`);i&&o&&(n.certfile=i,n.keyfile=o);let f=this.get(`${r}:_authToken`);if(f)return n.token=f,n;let c=this.get(`${r}:username`),a=this.get(`${r}:_password`);if(c&&a){n.username=c,n.password=Buffer.from(a,"base64").toString("utf8");let l=`${n.username}:${n.password}`;return n.auth=Buffer.from(l,"utf8").toString("base64"),n}let s=this.get(`${r}:_auth`);if(s){let p=Buffer.from(s,"base64").toString("utf8").split(":");return n.username=p.shift(),n.password=p.join(":"),n.auth=s,n}return n}},G=Symbol("loadError"),R=Symbol("valid"),we=class{#n;#r=null;#e={};constructor(t){this.#n=Object.create(t&&t.data),this[R]=!0}get data(){return this.#n}get valid(){return this[R]}set source(t){if(this.#r)throw new Error("cannot set ConfigData source more than once");this.#r=t}get source(){return this.#r}set loadError(t){if(this[G]||Object.keys(this.#e).length)throw new Error("cannot set ConfigData loadError after load");this[G]=t}get loadError(){return this[G]}set raw(t){if(Object.keys(this.#e).length||this[G])throw new Error("cannot set ConfigData raw after load");this.#e=t}get raw(){return this.#e}};lt.exports=me});var yt=O(j=>{"use strict";var be=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),Ar=be(e=>{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.sync=e.isexe=void 0;var t=E("fs"),r=E("fs/promises"),n=async(c,a={})=>{let{ignoreErrors:s=!1}=a;try{return o(await(0,r.stat)(c),a)}catch(l){let p=l;if(s||p.code==="EACCES")return!1;throw p}};e.isexe=n;var i=(c,a={})=>{let{ignoreErrors:s=!1}=a;try{return o((0,t.statSync)(c),a)}catch(l){let p=l;if(s||p.code==="EACCES")return!1;throw p}};e.sync=i;var o=(c,a)=>c.isFile()&&f(c,a),f=(c,a)=>{let s=a.uid??process.getuid?.(),l=a.groups??process.getgroups?.()??[],p=a.gid??process.getgid?.()??l[0];if(s===void 0||p===void 0)throw new Error("cannot get uid or gid");let d=new Set([p,...l]),u=c.mode,y=c.uid,b=c.gid,h=parseInt("100",8),A=parseInt("010",8),x=parseInt("001",8),g=h|A;return!!(u&x||u&A&&d.has(b)||u&h&&y===s||u&g&&s===0)}}),Pr=be(e=>{"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.sync=e.isexe=void 0;var t=E("fs"),r=E("fs/promises"),n=E("path"),i=async(a,s={})=>{let{ignoreErrors:l=!1}=s;try{return c(await(0,r.stat)(a),a,s)}catch(p){let d=p;if(l||d.code==="EACCES")return!1;throw d}};e.isexe=i;var o=(a,s={})=>{let{ignoreErrors:l=!1}=s;try{return c((0,t.statSync)(a),a,s)}catch(p){let d=p;if(l||d.code==="EACCES")return!1;throw d}};e.sync=o;var f=(a,s)=>{let{pathExt:l=process.env.PATHEXT||""}=s,p=l.split(n.delimiter);if(p.indexOf("")!==-1)return!0;for(let d of p){let u=d.toLowerCase(),y=a.substring(a.length-u.length).toLowerCase();if(u&&y===u)return!0}return!1},c=(a,s,l)=>a.isFile()&&f(s,l)}),Sr=be(e=>{"use strict";Object.defineProperty(e,"__esModule",{value:!0})}),ut=j&&j.__createBinding||(Object.create?function(e,t,r,n){n===void 0&&(n=r);var i=Object.getOwnPropertyDescriptor(t,r);(!i||("get"in i?!t.__esModule:i.writable||i.configurable))&&(i={enumerable:!0,get:function(){return t[r]}}),Object.defineProperty(e,n,i)}:function(e,t,r,n){n===void 0&&(n=r),e[n]=t[r]}),Nr=j&&j.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),pt=j&&j.__importStar||function(){var e=function(t){return e=Object.getOwnPropertyNames||function(r){var n=[];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(n[n.length]=i);return n},e(t)};return function(t){if(t&&t.__esModule)return t;var r={};if(t!=null)for(var n=e(t),i=0;i{var{isexe:Tr,sync:Cr}=yt(),{join:kr,delimiter:qr,sep:mt,posix:wt}=E("path"),bt=process.platform==="win32",xt=new RegExp(`[${wt.sep}${mt===wt.sep?"":mt}]`.replace(/(\\)/g,"\\$1")),Rr=new RegExp(`^\\.${xt.source}`),Et=e=>Object.assign(new Error(`not found: ${e}`),{code:"ENOENT"}),jt=(e,{path:t=process.env.PATH,pathExt:r=process.env.PATHEXT,delimiter:n=qr})=>{let i=e.match(xt)?[""]:[...bt?[process.cwd()]:[],...(t||"").split(n)];if(bt){let o=r||[".EXE",".CMD",".BAT",".COM"].join(n),f=o.split(n).flatMap(c=>[c,c.toLowerCase()]);return e.includes(".")&&f[0]!==""&&f.unshift(""),{pathEnv:i,pathExt:f,pathExtExe:o}}return{pathEnv:i,pathExt:[""]}},vt=(e,t)=>{let r=/^".*"$/.test(e)?e.slice(1,-1):e;return(!r&&Rr.test(t)?t.slice(0,2):"")+kr(r,t)},Ot=async(e,t={})=>{let{pathEnv:r,pathExt:n,pathExtExe:i}=jt(e,t),o=[];for(let f of r){let c=vt(f,e);for(let a of n){let s=c+a;if(await Tr(s,{pathExt:i,ignoreErrors:!0})){if(!t.all)return s;o.push(s)}}}if(t.all&&o.length)return o;if(t.nothrow)return null;throw Et(e)},Lr=(e,t={})=>{let{pathEnv:r,pathExt:n,pathExtExe:i}=jt(e,t),o=[];for(let f of r){let c=vt(f,e);for(let a of n){let s=c+a;if(Cr(s,{pathExt:i,ignoreErrors:!0})){if(!t.all)return s;o.push(s)}}}if(t.all&&o.length)return o;if(t.nothrow)return null;throw Et(e)};At.exports=Ot;Ot.sync=Lr});var U,xe=Q(()=>{"use strict";U="yarn-plugin-npmrc"});var St={};z(St,{throwError:()=>J});function J(e){throw new V.ReportError(V.MessageName.UNNAMED,`[${U}] ${e.message||e}`)}var V,Ee=Q(()=>{"use strict";V=E("@yarnpkg/core");xe()});var Tt={};z(Tt,{loadNpmrc:()=>Dr});async function Dr(e){let t="";try{t=$t.default.realpathSync(_t.default.sync("npm"))}catch{J(`Couldn't find "npm" executable to help read the config`)}let r=["silly","verbose","info","http","timing","notice","warn","error"],n=r.indexOf(process.env.NPM_CONFIG_LOGLEVEL||process.env.npm_config_loglevel||"warn"),i=(o,...f)=>{r.indexOf(o){"use strict";Nt=Z(ft()),$t=Z(E("fs")),_t=Z(Pt());xe();Ee()});var Wr={};z(Wr,{default:()=>Gr});var kt=E("@yarnpkg/core"),Mr={npmrcAuthEnabled:{description:"Attempt to read auth info from .npmrc for all registry requests",type:kt.SettingsType.BOOLEAN,default:!1}},Br="npmrcAuthEnabled",X,K,je={},qt,Hr=e=>{qt=e.getWorkspaceByCwd(e.cwd).cwd},Ir=async(e,t,{configuration:r})=>{if(!r.get(Br)||!r.projectCwd)return e;if(t in je)return je[t];if(K)throw K;if(!X){let{loadNpmrc:o}=await Promise.resolve().then(()=>(Ct(),Tt));try{X=await o({projectRoot:r.projectCwd,workspaceRoot:qt||r.projectCwd})}catch(f){throw K=f,K}}let n=X.getCredentialsByURI(t);if(Object.keys(n).length===0&&!t.endsWith("/")&&(n=X.getCredentialsByURI(`${t}/`)),n.certfile||n.keyfile){let{throwError:o}=await Promise.resolve().then(()=>(Ee(),St));o(`This plugin does not support certfile or keyfile auth (for registry "${t}")`)}let i;return"token"in n?i=`Bearer ${n.token}`:"auth"in n?i=`Basic ${n.auth}`:i=e,je[t]=i,i},Fr={hooks:{validateProject:Hr,getNpmAuthenticationHeader:Ir},configuration:Mr},Gr=Fr;return Ft(Wr);})(); +return plugin; +} +}; diff --git a/.yarnrc.yml b/.yarnrc.yml index 6f0320cdb..5cc982284 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -8,6 +8,14 @@ npmPreapprovedPackages: - lage - workspace-tools +# This plugin simplifies auth for the release pipeline by reusing .npmrc. +# It's enabled (npmrcAuthEnabled) only for ADO publish by preparePublishRegistry.ts. +# https://github.com/ecraig12345/yarn-plugins/tree/main/plugins/npmrc +plugins: + - checksum: ede432ed6c2f151ac6791edcb08ec11ac23bb73f7597bf2dd6e7b2f07c67ed1fe55d7135bcc4cd137bbc940c824d1fccf784966c309186e847c5051b8ddb2dea + path: .yarn/plugins/@yarnpkg/plugin-npmrc.cjs + spec: 'https://raw.githubusercontent.com/ecraig12345/yarn-plugins/npmrc_v0.4.0/plugins/npmrc/dist/plugin.js' + preferReuse: true yarnPath: .yarn/releases/yarn-4.14.1.cjs diff --git a/package.json b/package.json index 8665ea963..37e0101d7 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "prepare": "husky", "lint": "lage lint", "release:canary": "yarn beachball canary -y --scope packages/beachball --tag next", - "release:others": "yarn beachball publish -y --scope '!packages/beachball'", + "release:others": "yarn beachball publish -y --scope '!packages/beachball' --scope '!packages/p-graph'", "release:docs": "echo \"Run this from the docs folder instead\" && exit 1", "syncpack:check": "syncpack lint", "syncpack:update": "syncpack fix", diff --git a/packages/esrp-npm-release/.depcheckrc.yml b/packages/esrp-npm-release/.depcheckrc.yml new file mode 100644 index 000000000..c61a290c9 --- /dev/null +++ b/packages/esrp-npm-release/.depcheckrc.yml @@ -0,0 +1,7 @@ +ignore-path: ../../.gitignore + +ignores: + # used in `test` script via cross-env, not in source + - cross-env + # specified at root + - '@jest/globals' diff --git a/packages/esrp-npm-release/README.md b/packages/esrp-npm-release/README.md new file mode 100644 index 000000000..2fe3e8967 --- /dev/null +++ b/packages/esrp-npm-release/README.md @@ -0,0 +1,263 @@ +# @microsoft/esrp-npm-release + +Helper for teams within Microsoft that would like to [use ESRP to release npm packages](https://eng.ms/docs/microsoft-security/identity/trust-and-security-services/tss-release-distribute/tss-release-esrp-parent/oss-publishing/releasing-open-source/npmjs) in the correct order. + +This tool replaces the `EsrpRelease` ADO task with a direct ESRP API integration that (when used together with `beachball`) respects **dependency-topological ordering** of packages. This means that if publishing fails partway through, or someone accidentally installs a new version while publishing is still in progress, there will never be any dependency references to package versions that don't yet exist on the registry. The tool also supports retrying the release stage in ADO without repeating already-published layers. + +One unfortunate thing about using the API is that it only accepts blob storage URLs, so it requires an extra step of temporarily uploading files to a "staging" storage account. This tool automates that process (including cleanup), but you'll need to create an extra storage account and service connection. + +## Overview + +This tool relies on the following: + +- Output folder from `beachball publish --pack-to-path ` or [in the same format](#packed-packages-format) +- ESRP Azure resources configured per their guides + - An ESRP-onboarded app registration in a production tenant (need client ID and tenant ID) + - Production tenant key vault storing the ESRP auth certificate and request signing certificate (PFX format, base64-encoded) + - ADO Azure Resource Manager service connection with access to the app registration and key vault +- Specific to this tool: [staging resources](#staging-storage-account-setup) + - Azure Blob Storage account in your team's subscription (in the corp tenant) to temporarily host zips of packages + - Managed identity with the right RBAC roles on the storage account + - ADO Azure Resource Manager service connection configured to use that identity + + + +## Packed packages format + +Running `beachball publish --pack-to-path ` produces a directory with this shape. If there are 10+ layers, the directories must be zero-padded (e.g. `01`, `02`, ..., `10`, etc.) to ensure correct lexical ordering. + +``` +/ +├── 01/ # Layer 1: packages with no internal dependencies +│ ├── pkg-a-1.0.0.tgz +│ └── pkg-b-2.0.0.tgz +├── 02/ # Layer 2: packages that depend only on layer 1 +│ └── pkg-c-3.0.0.tgz +├── 03/ +│ └── ... +... +``` + +Each numbered directory is a **dependency-topological layer**: the packages in layer 1 have no internal dependencies, or none within the set of packages being published. Packages in layer `N` may only depend on packages in layers `1..N-1`. The tool releases layers in numeric order, so that by the time a layer is published, every internal dependency version it references is already on the registry. + +## Staging storage account setup + +This tool needs an Azure Blob Storage account in a subscription you control to: + +- Temporarily host the zipped layers (in a container named `staging`) so ESRP can download them via SAS URL. +- Persist retry state (in a container named `release-state`) so ADO stage retries can resume from where a previous attempt left off. + +Both containers are created on demand by the tool — you don't need to pre-create them. + +The Bicep template at [`.ado/roleAssignments.bicep`](https://github.com/microsoft/beachball/blob/main/.ado/roleAssignments.bicep) provisions everything in one shot: the storage account itself, a user-assigned managed identity, the required RBAC role assignments, and a lifecycle management policy. + +The commands below reuse the same subscription, resource group, storage account, and managed identity names, so set them once in your shell to keep the snippets copy-pasteable: + +```bash +SUBSCRIPTION="" +RESOURCE_GROUP="" +STORAGE_ACCOUNT="" +MANAGED_IDENTITY="" +``` + +### 1. Create the resource group + +Resource groups aren't created by the Bicep template. Either use an existing group, or run the command below to create a new one. (To see available locations: `az account list-locations --output tsv --query "[].name"`) + +```bash +az group create \ + --subscription "$SUBSCRIPTION" \ + --name "$RESOURCE_GROUP" \ + --location +``` + +### 2. Deploy the Bicep template + +This creates the storage account, a user-assigned managed identity, the two required data-plane role assignments, and a blob lifecycle policy (see [the notes below](#about-the-rbac-roles-and-lifecycle-policy) for what each piece is for). + +Preview the changes first. If a storage account with the given name already exists in the resource group, its properties are reconciled to match the template, and this command will show the diff. + +```bash +az deployment group what-if \ + --subscription "$SUBSCRIPTION" \ + --resource-group "$RESOURCE_GROUP" \ + --template-file roleAssignments.bicep \ + --parameters \ + stagingStorageName="$STORAGE_ACCOUNT" \ + managedIdentityName="$MANAGED_IDENTITY" +``` + +Apply changes: + +```bash +az deployment group create \ + --subscription "$SUBSCRIPTION" \ + --resource-group "$RESOURCE_GROUP" \ + --template-file roleAssignments.bicep \ + --parameters \ + stagingStorageName="$STORAGE_ACCOUNT" \ + managedIdentityName="$MANAGED_IDENTITY" +``` + +The deployment is idempotent — re-running it reconciles drift in the storage account properties, role assignments, and lifecycle policy. The storage account name will be passed to the tool as `STAGING_STORAGE_ACCOUNT_NAME`. + +#### About the RBAC roles and lifecycle policy + +The template assigns two **data-plane** roles to the managed identity at the storage account scope. Control-plane roles like `Contributor` or `Owner` are **not sufficient** — without the roles below, you'd see a 403 error like "This request is not authorized to perform this operation using this permission" the first time the tool tried to list or write a blob. + +- **Storage Blob Data Contributor**: List, read, write, and delete blobs in the `staging` and `release-state` containers, and create the containers on first use. +- **Storage Blob Delegator**: Mint short-lived user-delegation SAS tokens that ESRP uses to download staged zips. + +RBAC propagation usually takes less than five minutes. If a freshly-assigned role still produces 403s, wait a few minutes and retry. + +The template also configures an [Azure Storage lifecycle management policy](https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-overview) on the staging storage account. It cleans up `release-state` blobs automatically after (as of writing) 90 days, and `staging` blobs after 3 days. (The release tool attempts to delete the `staging` blobs immediately after the release, but the policy provides a fallback.) + +### 3. Create a service connection + +In your ADO project, create an **Azure Resource Manager** service connection: + +- **Identity type**: Managed identity (automatically configures Workload Identity Federation) +- **Managed identity details**: Choose your subscription, resource group, and identity +- **Azure scope**: Choose the same subscription and resource group +- **Service Connection Name**: Choose any name and make note of it for later + +The release pipeline (later) passes the connection's name to `AzureCLI@2` as `azureSubscription`. It uses the option `addSpnToEnvironment: true` to obtain a federated `idToken` that this tool exchanges for an AAD access token at runtime — there are no long-lived secrets to manage. + +## Pipeline setup + +This tool is designed to run in an Azure DevOps release pipeline using [1ES Pipeline Templates](https://eng.ms/docs/coreai/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/overview). The typical setup uses two pipelines: + +### Prepublish pipeline (CI/build) + +The prepublish pipeline builds the repo, packs the packages, and publishes them as a pipeline artifact. It should: + +1. Build and test the repo +2. Run `beachball publish --pack-to-path '$(Build.StagingDirectory)/packed-packages'` to create a folder with numbered subdirectories containing package `.tgz` files in dependency-topological layers +3. Publish two pipeline artifacts via `templateContext.outputs`: + - `packed-packages`: the packed `.tgz` files (the output of `--pack-to-path`) + - `release-api-tool`: the bundled `dist/index.js` from this package + +See https://github.com/microsoft/beachball/blob/main/.ado/publish.yml for a full example. + +```yaml +templateContext: + outputs: + - output: pipelineArtifact + artifactName: packed-packages + targetPath: $(Build.StagingDirectory)/packed-packages + - output: pipelineArtifact + artifactName: release-api-tool + # or the appropriate path in your repo + targetPath: $(Build.SourcesDirectory)/node_modules/@microsoft/esrp-npm-release/dist/index.js +``` + +### Release pipeline + +The release pipeline is triggered on prepublish pipeline completion, downloads the artifacts from the prepublish pipeline, and runs this tool. + +The job should be configured as a `releaseJob` with `isProduction: true` in `templateContext`. It downloads the packed packages and tool as pipeline artifact inputs. + +Be sure to fill in all the ``! See https://github.com/microsoft/beachball/blob/main/.ado/release.yml for a full example. + +```yaml +resources: + pipelines: + # "prepublish" is an arbitrary name which must match publishPipelineAlias below + - pipeline: prepublish + project: + source: + trigger: + branches: + include: + - main + +variables: + publishPipelineAlias: prepublish + packagesArtifactName: packed-packages + releaseApiToolArtifactName: release-api-tool + +extends: + template: <1ES PT template> + parameters: + pool: + name: + vmImage: windows-latest + os: windows + + stages: + - stage: main_release + displayName: Publish packages + jobs: + - job: npm_release + displayName: NPM to npmjs.com + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + pipeline: ${{ variables.publishPipelineAlias }} + artifactName: ${{ variables.packagesArtifactName }} + targetPath: $(Agent.BuildDirectory)\${{ variables.packagesArtifactName }} + - input: pipelineArtifact + pipeline: ${{ variables.publishPipelineAlias }} + artifactName: ${{ variables.releaseApiToolArtifactName }} + targetPath: $(Agent.BuildDirectory)\${{ variables.releaseApiToolArtifactName }} + + steps: + - task: UseNode@1 + displayName: Install Node.js 24 + inputs: + version: 24.x + + # Get credentials that will be used to temporarily upload zips to the staging storage account + # in your team's Azure subscription + - task: AzureCLI@2 + displayName: Get credentials for staging blob storage + inputs: + azureSubscription: + scriptType: pscore + scriptLocation: inlineScript + addSpnToEnvironment: true + inlineScript: | + Write-Host "##vso[task.setvariable variable=STAGING_TENANT_ID]$env:tenantId" + Write-Host "##vso[task.setvariable variable=STAGING_CLIENT_ID]$env:servicePrincipalId" + Write-Host "##vso[task.setvariable variable=STAGING_ID_TOKEN;issecret=true]$env:idToken" + + # Fetch ESRP certificates from the production tenant key vault + - task: AzureKeyVault@2 + displayName: Get ESRP certificates from Key Vault + inputs: + azureSubscription: + KeyVaultName: + SecretsFilter: , + + # Run the tool + - script: node $(Agent.BuildDirectory)\${{ variables.releaseApiToolArtifactName }}\index.js + displayName: Publish using ESRP Release API + retryCountOnTaskFailure: 3 + env: + PACKED_PACKAGES_PATH: $(Agent.BuildDirectory)\${{ variables.packagesArtifactName }} + + # Staging storage credentials + STAGING_STORAGE_ACCOUNT_NAME: + # set above by AzureCLI@2 but must be mapped in + STAGING_CLIENT_ID: $(STAGING_CLIENT_ID) + STAGING_TENANT_ID: $(STAGING_TENANT_ID) + STAGING_ID_TOKEN: $(STAGING_ID_TOKEN) + + # ESRP credentials (certs fetched above by AzureKeyVault@2) + ESRP_AUTH_CERT: $() + ESRP_REQUEST_SIGNING_CERT: $() + ESRP_TENANT_ID: + ESRP_CLIENT_ID: + + # Release info + ESRP_PRODUCT_NAME: + ESRP_NPM_TAG: # optional, default "latest" or inferred from publishConfig + # ESRP_USER is optional and provides a default value for other user-related options + ESRP_USER: + ESRP_CREATED_BY: # optional if ESRP_USER is set + ESRP_APPROVERS: # auto-approved; comma-separated; optional if ESRP_USER is set + ESRP_OWNERS: # comma-separated; optional if ESRP_USER is set + ESRP_DRI_EMAIL: # optional if ESRP_USER is set +``` diff --git a/packages/esrp-npm-release/bin/esrp-npm-release.js b/packages/esrp-npm-release/bin/esrp-npm-release.js new file mode 100755 index 000000000..924fff123 --- /dev/null +++ b/packages/esrp-npm-release/bin/esrp-npm-release.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/index.js'; diff --git a/packages/esrp-npm-release/eslint.config.js b/packages/esrp-npm-release/eslint.config.js new file mode 100644 index 000000000..30d2ba79a --- /dev/null +++ b/packages/esrp-npm-release/eslint.config.js @@ -0,0 +1,10 @@ +// @ts-check +import { getConfig } from '@microsoft/beachball-scripts/config/eslint.ts'; + +export default getConfig(import.meta.dirname, { + rules: { + // Use Logger instead or throw errors + 'no-console': 'error', + '@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }], + }, +}); diff --git a/packages/esrp-npm-release/jest.config.cjs b/packages/esrp-npm-release/jest.config.cjs new file mode 100644 index 000000000..77c880d44 --- /dev/null +++ b/packages/esrp-npm-release/jest.config.cjs @@ -0,0 +1,4 @@ +// @ts-check +const { getESMConfig } = require('@microsoft/beachball-scripts/config/jest.cjs'); + +module.exports = getESMConfig(); diff --git a/packages/esrp-npm-release/package.json b/packages/esrp-npm-release/package.json new file mode 100644 index 000000000..36fa60120 --- /dev/null +++ b/packages/esrp-npm-release/package.json @@ -0,0 +1,38 @@ +{ + "name": "@microsoft/esrp-npm-release", + "version": "0.1.0", + "license": "UNLICENSED", + "description": "ESRP npm release helper", + "bin": "bin/esrp-npm-release.js", + "private": true, + "exports": null, + "type": "module", + "engines": { + "node": ">=22.18.0" + }, + "files": [ + "bin", + "dist", + "NOTICE.txt" + ], + "scripts": { + "build": "yarn run -T tsc --pretty", + "bundle": "node ../../scripts/bundleNode.ts", + "depcheck": "yarn run -T depcheck .", + "lint": "yarn run -T eslint --color --max-warnings=0 src", + "start": "node src/index.ts", + "test": "cross-env NODE_OPTIONS='--experimental-vm-modules' yarn run -T jest" + }, + "devDependencies": { + "@azure/core-auth": "^1.10.1", + "@azure/msal-node": "^5.1.5", + "@azure/storage-blob": "^12.31.0", + "@microsoft/beachball-scripts": "workspace:^", + "@types/jws": "^3.2.11", + "@types/yazl": "^3.3.1", + "cross-env": "^10.1.0", + "execa": "^5.1.1", + "jws": "patch:jws@npm%3A4.0.1#~/.yarn/patches/jws-npm-4.0.1-0d8c257cbe.patch", + "yazl": "^3.3.1" + } +} diff --git a/packages/esrp-npm-release/src/ESRPReleaseService.ts b/packages/esrp-npm-release/src/ESRPReleaseService.ts new file mode 100644 index 000000000..b5fee8c86 --- /dev/null +++ b/packages/esrp-npm-release/src/ESRPReleaseService.ts @@ -0,0 +1,354 @@ +import { + BlobSASPermissions, + generateBlobSASQueryParameters, + type BlobServiceClient, + type BlockBlobClient, + type ContainerClient, + type UserDelegationKey, +} from '@azure/storage-blob'; +import { randomUUID } from 'crypto'; +import { getAadToken, type AccessToken } from './auth/getAadToken.ts'; +import { getKeyAndCertificatesFromPFX } from './auth/signing.ts'; +import { + createNpmReleaseRequest, + redactReleaseRequest, + type CreateNpmReleaseRequestMessageParams, +} from './esrpApi/npmRelease.ts'; +import { esrpApiEndpoint, getReleaseDetails, getReleaseStatus, submitRelease } from './esrpApi/releaseHttp.ts'; +import type { ReleaseResultMessage } from './types/api.ts'; +import type { Logger } from './utils/Logger.ts'; +import { ReleaseError } from './utils/ReleaseError.ts'; + +interface PerReleaseCredentials { + esrpAccessToken: AccessToken; + userDelegationKey: UserDelegationKey; +} + +interface CreateESRPReleaseServiceParams { + logger: Logger; + + /** ESRP Release Service client ID */ + clientId: string; + /** ESRP Release Service tenant ID */ + tenantId: string; + /** ESRP Auth cert PFX content */ + authCertificatePfx: string; + /** ESRP JWS request signing cert PFX content */ + requestSigningCertificatePfx: string; + + /** Azure blob storage client for staging artifact files */ + stagingBlobServiceClient: BlobServiceClient; +} + +interface ESRPReleaseServiceParams extends CreateESRPReleaseServiceParams { + stagingContainerClient: ContainerClient; +} + +export interface CreateReleaseParams { + /** Local file path to upload */ + filePath: string; + stagingBlobPathPrefix: string; + /** Info for creating the release request */ + releaseRequestParams: Omit< + CreateNpmReleaseRequestMessageParams, + 'correlationId' | 'file' | 'requestSigningCertificates' | 'requestSigningKey' + >; +} + +const stagingContainerName = 'staging'; + +/** + * Orchestrates ESRP Release API operations for one or more files. + * Handles AAD authentication, blob staging, SAS token generation, JWS signing, release submission, + * and polling for completion. + * + * Based on https://github.com/microsoft/vscode/blob/main/build/azure-pipelines/common/publish.ts + * called by https://github.com/microsoft/vscode/blob/main/build/azure-pipelines/product-publish.yml#L106 + * + * (The original implementation has an additional step of acquiring a lease on the staging blob to + * prevent issues with multiple concurrent runs, but this is not an anticipated scenario for npm.) + */ +export class ESRPReleaseService { + /** + * Construct a service instance, ensuring the staging container exists upfront. + * AAD and SAS tokens are acquired per release (in `createRelease`) in case prior releases are slow + * (unclear if this would be an issue in practice). + */ + public static async create(params: CreateESRPReleaseServiceParams): Promise { + const { logger } = params; + let stagingContainerClient: ContainerClient; + try { + logger.log(`Getting client and ensuring staging container "${stagingContainerName}" exists`); + stagingContainerClient = params.stagingBlobServiceClient.getContainerClient(stagingContainerName); + await stagingContainerClient.createIfNotExists(); + } catch (err) { + throw new ReleaseError(`Error ensuring staging container "${stagingContainerName}" exists`, { cause: err }); + } + return new ESRPReleaseService({ ...params, stagingContainerClient }); + } + + readonly #logger: Logger; + readonly #clientId: string; + readonly #tenantId: string; + readonly #authCertificatePfx: string; + readonly #requestSigningCertificates: string[]; + readonly #requestSigningKey: string; + readonly #stagingBlobServiceClient: BlobServiceClient; + readonly #stagingContainerClient: ContainerClient; + + private constructor(params: ESRPReleaseServiceParams) { + this.#logger = params.logger; + this.#clientId = params.clientId; + this.#tenantId = params.tenantId; + this.#authCertificatePfx = params.authCertificatePfx; + this.#stagingBlobServiceClient = params.stagingBlobServiceClient; + this.#stagingContainerClient = params.stagingContainerClient; + try { + this.#logger.log('Extracting request signing key and certificates from PFX'); + const { key, certificates } = getKeyAndCertificatesFromPFX(params.requestSigningCertificatePfx, this.#logger); + this.#requestSigningKey = key; + this.#requestSigningCertificates = certificates; + } catch (err) { + throw new ReleaseError(`Error extracting request signing key and certificates from PFX`, { cause: err }); + } + } + + /** + * Release a single file via ESRP and poll for its completion. Throws if not successful. + * + * Steps: + * 1. Acquire a fresh AAD access token and SAS token (re-acquired per release because + * previous releases may have been slow enough that prior tokens are near expiry) + * 2. Upload the file to the staging container + * 3. Submit the release request and poll until completion + * 4. Delete the staged blob + * + * The recommended bicep template includes a lifecycle management policy to clean up blobs + * after a given window (3 days as of writing). + */ + public async createRelease(params: CreateReleaseParams): Promise { + const { filePath, releaseRequestParams, stagingBlobPathPrefix } = params; + + // Acquire fresh credentials for each release in case earlier slow operations caused + // the previously-acquired AAD/SAS tokens to expire. + this.#logger.log('Acquiring fresh credentials for release'); + const credentials = await this.#acquireCredentials(); + + const correlationId = randomUUID(); + const blobName = `${stagingBlobPathPrefix}/${correlationId}`; + let blobClient: BlockBlobClient; + try { + blobClient = this.#stagingContainerClient.getBlockBlobClient(blobName); + } catch (err) { + throw new ReleaseError(`Error initializing blob client for staging upload`, { cause: err }); + } + + // filePath is -.zip + this.#logger.log(`Uploading ${filePath} to ${blobClient.url}`); + await blobClient.uploadFile(filePath).catch(err => { + throw new ReleaseError(`Error uploading file to staging storage`, { cause: err }); + }); + + try { + await this.#submitAndPollRelease({ + filePath, + correlationId, + sasBlobUrl: `${blobClient.url}?${this.#generateBlobSas(blobName, credentials.userDelegationKey)}`, + releaseRequestParams, + credentials, + }); + } finally { + this.#logger.log(`Deleting blob ${blobClient.url}`); + try { + await blobClient.delete(); + } catch (err) { + this.#logger.warn(`Failed to delete blob:`, err); + } + } + } + + /** Acquire a fresh AAD token and user delegation key for a single release. */ + async #acquireCredentials(): Promise { + const esrpAccessToken = await this.#getEsrpAccessToken(); + + let userDelegationKey: UserDelegationKey; + try { + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + this.#logger.log( + `Requesting user delegation key for staging storage account "${this.#stagingBlobServiceClient.accountName}"` + ); + userDelegationKey = await this.#stagingBlobServiceClient.getUserDelegationKey( + new Date(now - oneHour), + new Date(now + oneHour) + ); + } catch (err) { + throw new ReleaseError(`Error generating SAS token for staging blob access`, { cause: err }); + } + + return { esrpAccessToken, userDelegationKey }; + } + + async #getEsrpAccessToken(): Promise { + this.#logger.log(`Acquiring AAD access token for ESRP API at ${esrpApiEndpoint}`); + return await getAadToken({ + scopes: [`${esrpApiEndpoint}.default`], + clientId: this.#clientId, + tenantId: this.#tenantId, + auth: { certPfxContent: this.#authCertificatePfx }, + logger: this.#logger, + }).catch(err => { + throw new ReleaseError(`Error acquiring access token for ESRP API`, { cause: err }); + }); + } + + /** + * Generate a SAS token scoped to a single blob (read-only). Scoping to the specific blob + * (rather than the container) limits the blast radius if the SAS URL leaks: only this + * release's zip is readable, not every blob staged in the container. + */ + #generateBlobSas(blobName: string, userDelegationKey: UserDelegationKey): string { + this.#logger.log(`Generating SAS token for staging blob "${blobName}"`); + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + return generateBlobSASQueryParameters( + { + containerName: stagingContainerName, + blobName, + permissions: BlobSASPermissions.from({ read: true }), + startsOn: new Date(now - oneHour), + expiresOn: new Date(now + oneHour), + }, + userDelegationKey, + this.#stagingBlobServiceClient.accountName + ).toString(); + } + + async #submitAndPollRelease( + params: Omit & { + correlationId: string; + sasBlobUrl: string; + credentials: PerReleaseCredentials; + } + ): Promise { + const { filePath, correlationId, sasBlobUrl, releaseRequestParams, credentials } = params; + + this.#logger.log(`Preparing to submit release`); + + const request = await createNpmReleaseRequest({ + ...releaseRequestParams, + correlationId, + requestSigningCertificates: this.#requestSigningCertificates, + requestSigningKey: this.#requestSigningKey, + file: { path: filePath, sasBlobUrl }, + }); + + this.#logger.log(`Sending request to ESRP API: ${JSON.stringify(redactReleaseRequest(request), null, 2)}`); + + const submitReleaseResult = await submitRelease({ + clientId: this.#clientId, + bearerToken: credentials.esrpAccessToken.token, + releaseRequest: request, + }); + + this.#logger.log(`Successfully submitted release ${submitReleaseResult.operationId}. Polling for completion...`); + + // Poll every 5 seconds, wait 60 minutes max -> poll 60/5*60=720 times + let releaseStatus: ReleaseResultMessage | undefined; + let lastLoggedStatus: string | undefined; + for (let i = 0; i < 720; i++) { + await new Promise(c => setTimeout(c, 5000)); + + // AAD client-credential tokens are typically valid for ~1 hour. Since polling can run + // for up to 60 minutes (and was preceded by upload + submit), the original token can + // expire mid-poll. Refresh proactively when within 5 minutes of expiry. + await this.#refreshEsrpAccessTokenIfNeeded(credentials); + + releaseStatus = await getReleaseStatus({ + clientId: this.#clientId, + bearerToken: credentials.esrpAccessToken.token, + releaseId: submitReleaseResult.operationId, + }); + + // Log only on status changes to avoid spamming the log on every poll + if (releaseStatus.status !== lastLoggedStatus) { + this.#logger.log(`Release status: "${releaseStatus.status}"`); + lastLoggedStatus = releaseStatus.status; + } + + if (this.#checkReleaseStatus(releaseStatus)) { + break; + } + } + + if (releaseStatus?.status !== 'pass') { + throw new ReleaseError( + `Timed out waiting for release. Most recent status API response: ${JSON.stringify(releaseStatus, null, 2)}` + ); + } + + // Packages are already published at this point. Fetching details is diagnostic only — + // if it fails, log a warning so the caller can still mark the layer published. + this.#logger.log(`Release ${submitReleaseResult.operationId} passed; fetching release details`); + try { + const releaseDetails = await getReleaseDetails({ + clientId: this.#clientId, + bearerToken: credentials.esrpAccessToken.token, + releaseId: submitReleaseResult.operationId, + }); + this.#logger.log('Release details:', JSON.stringify(redactReleaseRequest(releaseDetails), null, 2)); + } catch (err) { + this.#logger.warn( + `Release ${submitReleaseResult.operationId} succeeded but fetching details failed; ` + + `continuing so the layer can be marked published:`, + err + ); + } + } + + /** + * AAD client-credential tokens are typically valid for ~1 hour. Since polling can run + * for up to 60 minutes (and was preceded by upload + submit), the original token can + * expire mid-poll. Refresh proactively when within 5 minutes of expiry. + */ + async #refreshEsrpAccessTokenIfNeeded(credentials: PerReleaseCredentials): Promise { + const { expiresOnTimestamp, refreshAfterTimestamp } = credentials.esrpAccessToken; + const refreshAt = refreshAfterTimestamp ?? expiresOnTimestamp - 5 * 60 * 1000; + if (Date.now() >= refreshAt) { + this.#logger.log('AAD access token near expiry, refreshing'); + credentials.esrpAccessToken = await this.#getEsrpAccessToken(); + } + } + + /** + * Returns true if the release status is 'pass', or false if in progress. + * Throws `ReleaseError` on issues. + */ + #checkReleaseStatus(releaseStatus: ReleaseResultMessage): boolean { + const releaseStr = JSON.stringify(releaseStatus, null, 2); + const fullStatusApiResponse = `Full status API response: ${releaseStr}`; + + if (releaseStatus.status === 'pass') { + this.#logger.log(`Release ${releaseStatus.operationId} passed. Last status details: ${releaseStr}`); + return true; + } + + // Check for a 404 on publish and give a specific error + const errorDetails = (releaseStatus.errorInfo || releaseStatus.errorinfo)?.details?.errors; + if (errorDetails && /^404.*?PUT.*?registry\.npmjs\.org/.test(errorDetails)) { + throw new ReleaseError( + `Release failed with 404 on npm publish: ${errorDetails}\nThis usually indicates an auth issue, ` + + `such as expired credentials or missing permissions. Please contact the ESRP team for help.\n\n` + + fullStatusApiResponse + ); + } + // TODO: mismatch with values included in provided types + if ((releaseStatus.status as unknown) === 'aborted' || releaseStatus.status === 'cancelled') { + throw new ReleaseError(`Release was aborted. ${fullStatusApiResponse}`); + } + if (releaseStatus.status !== 'inprogress') { + throw new ReleaseError(`Unexpected release status "${releaseStatus.status}". ${fullStatusApiResponse}`); + } + return false; + } +} diff --git a/packages/esrp-npm-release/src/__fixtures__/MockLogger.ts b/packages/esrp-npm-release/src/__fixtures__/MockLogger.ts new file mode 100644 index 000000000..6b54f2b10 --- /dev/null +++ b/packages/esrp-npm-release/src/__fixtures__/MockLogger.ts @@ -0,0 +1,47 @@ +import { jest } from '@jest/globals'; +import { Logger, type LogMethod } from '../utils/Logger'; + +type LogMocks = { + [K in LogMethod]: jest.Mock; +}; + +/** + * Capturing test double for `Logger`. All methods record their args into `lines` (with a + * `[method]` prefix) and into `mocks[method]`/`mocks.startGroup`/`mocks.endGroup` for + * fine-grained inspection. + */ +export class MockLogger extends Logger { + /** All log/warn/error lines, in call order, prefixed with `[method]`. */ + public readonly lines: string[]; + public readonly mocks: LogMocks; + #replacePaths: Record = {}; + + public constructor() { + const lines: string[] = []; + const capture = (method: LogMethod, ...args: unknown[]) => { + let line = args.join(' '); + for (const [path, replacement] of Object.entries(this.#replacePaths)) { + line = line.replaceAll(path, replacement).replace(/\\/g, '/'); + } + lines.push(`[${method}] ${line}`); + }; + const mocks: LogMocks = { + log: jest.fn(capture.bind(null, 'log')), + warn: jest.fn(capture.bind(null, 'warn')), + error: jest.fn(capture.bind(null, 'error')), + }; + super(undefined, mocks); + this.lines = lines; + this.mocks = mocks; + } + + /** Convenience: get all captured lines as a single string, for snapshots. */ + public getOutput(): string { + return this.lines.join('\n'); + } + + /** Replace all occurrences of `path` with `replacement` in future log lines. */ + public addPath(path: string, replacement: string): void { + this.#replacePaths[path] = replacement; + } +} diff --git a/packages/esrp-npm-release/src/__fixtures__/mockAzure.ts b/packages/esrp-npm-release/src/__fixtures__/mockAzure.ts new file mode 100644 index 000000000..9524fd801 --- /dev/null +++ b/packages/esrp-npm-release/src/__fixtures__/mockAzure.ts @@ -0,0 +1,129 @@ +import { jest } from '@jest/globals'; +import type { + BlobDeleteResponse, + BlobItem, + BlobServiceClient, + BlobUploadCommonResponse, + BlockBlobClient, + BlockBlobUploadResponse, + ContainerClient, + ContainerCreateIfNotExistsResponse, + ServiceGetUserDelegationKeyResponse, +} from '@azure/storage-blob'; + +/** + * Fake `BlockBlobClient` exposing only the methods the production code calls. + * All async methods are `jest.fn()` so tests can assert calls and override behavior per-test. + */ +export type MockBlockBlobClient = Pick & + jest.Mocked>; + +export function createMockBlockBlobClient(url = 'https://mock.blob.core.windows.net/c/blob'): MockBlockBlobClient { + return { + url, + upload: jest.fn(() => Promise.resolve({} as BlockBlobUploadResponse)), + uploadFile: jest.fn(() => Promise.resolve({} as BlobUploadCommonResponse)), + delete: jest.fn(() => Promise.resolve({} as BlobDeleteResponse)), + }; +} + +/** + * Fake `ContainerClient` covering only the methods the production code calls. + * + * Stateful mode: `getBlockBlobClient(name)` returns a per-name blob client backed + * by an internal `Map`. Calls to `upload`/`uploadFile` add the blob to the listed set; + * `delete` removes it. `listBlobsFlat({ prefix })` reflects those changes, so tests can + * round-trip persistence behavior (write a blob, recreate state, see it listed). + * + * Basic mode: passing `blobClient` makes `getBlockBlobClient` ignore the name and always + * return that single client. Use this when a test only needs to assert the call args of one + * blob and doesn't care about list-after-write consistency. + */ +export interface MockContainerClient extends Pick { + createIfNotExists: jest.Mock; + getBlockBlobClient: jest.Mock<(name: string) => MockBlockBlobClient>; + listBlobsFlat: jest.Mock<(opts?: { prefix?: string }) => AsyncIterable>>; +} + +export function createMockContainerClient( + opts: { + accountName?: string; + containerName?: string; + /** Initial set of blob names to seed `listBlobsFlat` with (stateful mode). */ + blobNames?: string[]; + /** + * Single blob client returned from `getBlockBlobClient` regardless of name. + * Disables stateful tracking (uploads via this client won't appear in `listBlobsFlat`). + */ + blobClient?: MockBlockBlobClient; + } = {} +): MockContainerClient { + const accountName = opts.accountName ?? 'mockaccount'; + const containerName = opts.containerName ?? 'mockcontainer'; + + /** Set of "live" blob names */ + const liveNames = new Set(opts.blobNames ?? []); + + /** Build a per-name blob client whose mutating methods update `liveNames`. */ + function makeBlobClient(name: string): MockBlockBlobClient { + const url = `https://${accountName}.blob.core.windows.net/${containerName}/${name}`; + return { + url, + upload: jest.fn(() => { + liveNames.add(name); + return Promise.resolve({} as BlockBlobUploadResponse); + }), + uploadFile: jest.fn(() => { + liveNames.add(name); + return Promise.resolve({} as BlobUploadCommonResponse); + }), + delete: jest.fn(() => { + liveNames.delete(name); + return Promise.resolve({} as BlobDeleteResponse); + }), + }; + } + + return { + accountName, + containerName, + createIfNotExists: jest.fn(() => Promise.resolve({ succeeded: true } as ContainerCreateIfNotExistsResponse)), + getBlockBlobClient: jest.fn((name: string) => { + if (opts.blobClient) return opts.blobClient; + return makeBlobClient(name); + }), + listBlobsFlat: jest.fn( + // eslint-disable-next-line @typescript-eslint/require-await + async function* (filter?: { prefix?: string }) { + const prefix = filter?.prefix ?? ''; + for (const name of liveNames) { + if (name.startsWith(prefix)) yield { name }; + } + } + ), + }; +} + +/** + * Fake `BlobServiceClient` covering only what the production code uses. `getContainerClient` + * returns the same fake container by default. + */ +export interface MockBlobServiceClient extends Pick { + getContainerClient: jest.Mock<(name: string) => MockContainerClient>; + getUserDelegationKey: jest.Mock; +} + +export function createMockBlobServiceClient( + opts: { + accountName?: string; + containerClient?: MockContainerClient; + } = {} +): MockBlobServiceClient { + const accountName = opts.accountName ?? 'mockaccount'; + const containerClient = opts.containerClient ?? createMockContainerClient({ accountName }); + return { + accountName, + getContainerClient: jest.fn<(name: string) => MockContainerClient>().mockReturnValue(containerClient), + getUserDelegationKey: jest.fn(() => Promise.resolve({ value: 'mock-key' } as ServiceGetUserDelegationKeyResponse)), + }; +} diff --git a/packages/esrp-npm-release/src/__fixtures__/mockEnv.ts b/packages/esrp-npm-release/src/__fixtures__/mockEnv.ts new file mode 100644 index 000000000..47de85098 --- /dev/null +++ b/packages/esrp-npm-release/src/__fixtures__/mockEnv.ts @@ -0,0 +1,52 @@ +import type { EnvOptions } from '../types/EnvOptions'; + +/** A complete `EnvOptions` for tests, with sensible defaults. Override fields per-test. */ +export function createMockEnv(): EnvOptions { + return { + packedPackagesPath: '/tmp/packed', + esrp: { + productName: 'TestProduct', + npmTag: undefined, + createdBy: 'created@example.com', + driEmail: ['dri@example.com'], + owners: ['owner@example.com'], + approvers: ['approver@example.com'], + tenantId: 'esrp-tenant', + clientId: 'esrp-client', + authCertificatePfx: 'mock-auth-pfx', + requestSigningCertificatePfx: 'mock-signing-pfx', + }, + staging: { + storageAccountName: 'stagingaccount', + clientId: 'staging-client', + idToken: 'staging-id-token', + tenantId: 'staging-tenant', + }, + ado: { + agentTempDirectory: '/tmp/agent', + buildSourceVersion: 'abcdef0123456789', + buildRepositoryName: 'org/repo', + }, + }; +} + +/** Returns a fully-populated `process.env`-shaped object satisfying `getEnvOptions`. */ +export function createMockProcessEnv(overrides: Partial = {}): NodeJS.ProcessEnv { + return { + PACKED_PACKAGES_PATH: '/tmp/packed', + ESRP_PRODUCT_NAME: 'TestProduct', + ESRP_USER: 'test@example.com', + ESRP_TENANT_ID: 'esrp-tenant', + ESRP_CLIENT_ID: 'esrp-client', + ESRP_AUTH_CERT: 'mock-auth-pfx', + ESRP_REQUEST_SIGNING_CERT: 'mock-signing-pfx', + STAGING_STORAGE_ACCOUNT_NAME: 'stagingaccount', + STAGING_CLIENT_ID: 'staging-client', + STAGING_ID_TOKEN: 'staging-id-token', + STAGING_TENANT_ID: 'staging-tenant', + AGENT_TEMPDIRECTORY: '/tmp/agent', + BUILD_SOURCEVERSION: 'abcdef0123456789', + BUILD_REPOSITORY_NAME: 'org/repo', + ...overrides, + }; +} diff --git a/packages/esrp-npm-release/src/__fixtures__/mockEsrpHttp.ts b/packages/esrp-npm-release/src/__fixtures__/mockEsrpHttp.ts new file mode 100644 index 000000000..86fa9eac8 --- /dev/null +++ b/packages/esrp-npm-release/src/__fixtures__/mockEsrpHttp.ts @@ -0,0 +1,39 @@ +import { jest } from '@jest/globals'; +import type { ReleaseResultMessage } from '../types/api.ts'; +import type * as releaseHttp from '../esrpApi/releaseHttp.ts'; + +/** + * Programmable fakes for the `releaseHttp` module functions. Use with + * `jest.unstable_mockModule('../esrpApi/releaseHttp.ts', () => createMockEsrpHttp())`. + * + * Default behavior: + * - `submitRelease` resolves with `{ operationId: 'mock-op-id' }` + * - `getReleaseStatus` resolves with `{ status: 'pass' }` (override for polling tests) + * - `getReleaseDetails` resolves with `{}` + */ +export type MockEsrpHttp = jest.Mocked & { + /** + * Helper for `getReleaseStatus`: queue a sequence of statuses to be returned in order. + * After the queue is exhausted, the last status is returned indefinitely. + */ + queueStatuses: (statuses: string[]) => void; +}; + +export function createMockEsrpHttp(): MockEsrpHttp { + const getReleaseStatus = jest.fn(() => Promise.resolve({ status: 'pass' } as ReleaseResultMessage)); + return { + esrpApiEndpoint: 'https://api.esrp.microsoft.com/', + submitRelease: jest.fn(() => Promise.resolve({ operationId: 'mock-op-id' })), + getReleaseStatus, + getReleaseDetails: jest.fn(() => Promise.resolve({})), + queueStatuses: statuses => { + let i = 0; + // eslint-disable-next-line @typescript-eslint/require-await + getReleaseStatus.mockImplementation(async () => { + const status = statuses[Math.min(i, statuses.length - 1)]; + i++; + return { status } as ReleaseResultMessage; + }); + }, + }; +} diff --git a/packages/esrp-npm-release/src/__fixtures__/tempDir.ts b/packages/esrp-npm-release/src/__fixtures__/tempDir.ts new file mode 100644 index 000000000..cb863664e --- /dev/null +++ b/packages/esrp-npm-release/src/__fixtures__/tempDir.ts @@ -0,0 +1,65 @@ +import { afterAll, afterEach } from '@jest/globals'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +/** + * Per-test temp-directory helper. Returns a `getTempDir()` function that creates a fresh + * temp directory on first call (lazy) and is cleaned up automatically by an `afterEach` or `afterAll`. + * + * Call this **outside** of any lifecycle hooks (it registers the appropriate hook itself). + */ +export function setupTempDir(options?: { prefix?: string; cleanup?: 'afterEach' | 'afterAll' }): { + /** Get the path to the temp directory, creating it if necessary. */ + getTempDir: () => string; +} { + const { prefix = 'esrp-npm-release-test-', cleanup = 'afterEach' } = options || {}; + let tempDir: string | undefined; + + const afterHook = cleanup === 'afterAll' ? afterAll : afterEach; + afterHook(() => { + removeTempDir(tempDir); + tempDir = undefined; + }); + + return { + getTempDir: () => { + tempDir ??= fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return tempDir; + }, + }; +} + +/** Remove a temp directory and ignore errors */ +export function removeTempDir(tempDir: string | undefined): void { + try { + tempDir && fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +/** + * Build a fake "packed packages" directory at `parentDir` containing the requested layers. + * Each layer is a numbered subdirectory (e.g. "0", "1") with empty `.tgz` files inside. + * + * @example + * const packedDir = createPackedDir(getTempDir(), { + * '0': ['pkg-a-1.0.0.tgz'], + * '1': ['pkg-b-2.0.0.tgz', 'pkg-c-3.0.0.tgz'], + * }); + * + * @returns Path to the created packed-packages directory + */ +export function createPackedDir(parentDir: string, layers: Record): string { + const packedDir = path.join(parentDir, 'packed'); + fs.mkdirSync(packedDir, { recursive: true }); + for (const [layerName, files] of Object.entries(layers)) { + const layerDir = path.join(packedDir, layerName); + fs.mkdirSync(layerDir, { recursive: true }); + for (const file of files) { + fs.writeFileSync(path.join(layerDir, file), `mock contents of ${file}`); + } + } + return packedDir; +} diff --git a/packages/esrp-npm-release/src/__fixtures__/testCert.ts b/packages/esrp-npm-release/src/__fixtures__/testCert.ts new file mode 100644 index 000000000..b3d095361 --- /dev/null +++ b/packages/esrp-npm-release/src/__fixtures__/testCert.ts @@ -0,0 +1,160 @@ +import execa from 'execa'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { removeTempDir } from './tempDir'; + +let opensslAvailableCache: boolean | undefined; + +/** + * Synchronously check whether `openssl` is available on PATH. The result is cached so the + * subprocess is only spawned once per Jest worker. + */ +export function isOpensslAvailable(): boolean { + if (opensslAvailableCache === undefined) { + try { + execa.sync('openssl', ['version']); + opensslAvailableCache = true; + } catch { + opensslAvailableCache = false; + } + } + return opensslAvailableCache; +} + +export interface TestCert { + /** PEM-encoded end-entity (leaf) certificate */ + leafCertPem: string; + /** PEM-encoded CA certificate that signed the leaf */ + caCertPem: string; + /** PEM-encoded private key (PKCS#8, unencrypted) for the leaf cert */ + keyPem: string; + /** + * Base64-encoded PFX bundle containing the leaf private key, leaf certificate, and CA + * certificate. Suitable for passing to `getKeyAndCertificatesFromPFX`. + */ + pfxBase64: string; + /** Hex SHA1 thumbprint of the LEAF certificate, computed independently via openssl. */ + sha1ThumbprintHex: string; + /** Hex SHA256 thumbprint of the LEAF certificate, computed independently via openssl. */ + sha256ThumbprintHex: string; +} + +/** + * Used for LOCAL TEST FIXTURES ONLY (not actual authentication). + * + * Generate a fresh test certificate chain (leaf signed by a CA), private key, and PFX bundle + * synchronously via openssl. Use in a `beforeAll` after gating with `isOpensslAvailable()`. + * + * The PFX contains both the leaf and the CA certificate so tests can exercise the multi-cert + * extraction path in `getKeyAndCertificatesFromPFX` (including its `.reverse()` ordering). + * + * Throws if openssl is not available; callers should skip the suite first. + */ +export function generateTestCert(): TestCert { + if (!isOpensslAvailable()) { + throw new Error('openssl is not available on PATH'); + } + + // this is removed at the end of the function + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'esrp-npm-release-test-cert-')); + + try { + const caKeyPath = path.join(tempDir, 'ca-key.pem'); + const caCertPath = path.join(tempDir, 'ca-cert.pem'); + const leafKeyPath = path.join(tempDir, 'leaf-key.pem'); + const leafCsrPath = path.join(tempDir, 'leaf.csr'); + const leafCertPath = path.join(tempDir, 'leaf-cert.pem'); + const pfxPath = path.join(tempDir, 'chain.pfx'); + + // 1. Generate the CA: self-signed cert + its private key. + execa.sync('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + caKeyPath, + '-out', + caCertPath, + '-days', + '1', + '-subj', + '/CN=esrp-npm-release-test-ca', + ]); + + // 2. Generate the leaf private key + a CSR for it. + execa.sync('openssl', [ + 'req', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + leafKeyPath, + '-out', + leafCsrPath, + '-subj', + '/CN=esrp-npm-release-test-leaf', + ]); + + // 3. Sign the leaf CSR with the CA to produce the leaf certificate. + execa.sync('openssl', [ + 'x509', + '-req', + '-in', + leafCsrPath, + '-CA', + caCertPath, + '-CAkey', + caKeyPath, + '-CAcreateserial', + '-out', + leafCertPath, + '-days', + '1', + ]); + + // 4. Bundle leaf key + leaf cert + CA cert into a PFX (empty password matches the + // `getKeyAndCertificatesFromPFX` invocation). + execa.sync('openssl', [ + 'pkcs12', + '-export', + '-inkey', + leafKeyPath, + '-in', + leafCertPath, + '-certfile', + caCertPath, + '-out', + pfxPath, + '-password', + 'pass:', + ]); + + const leafCertPem = fs.readFileSync(leafCertPath, 'utf8'); + const caCertPem = fs.readFileSync(caCertPath, 'utf8'); + const keyPem = fs.readFileSync(leafKeyPath, 'utf8'); + const pfxBase64 = fs.readFileSync(pfxPath).toString('base64'); + + // Independently compute thumbprints of the leaf using openssl so tests don't rely on the + // implementation under test for expected values. `openssl x509 -fingerprint` emits + // "sha256 Fingerprint=AB:CD:..." — we strip the colons and lowercase to match `getThumbprint`. + const sha1ThumbprintHex = computeFingerprint(leafCertPath, 'sha1'); + const sha256ThumbprintHex = computeFingerprint(leafCertPath, 'sha256'); + + return { leafCertPem, caCertPem, keyPem, pfxBase64, sha1ThumbprintHex, sha256ThumbprintHex }; + } finally { + removeTempDir(tempDir); + } +} + +function computeFingerprint(certPath: string, algorithm: 'sha1' | 'sha256'): string { + const result = execa.sync('openssl', ['x509', '-in', certPath, '-noout', '-fingerprint', `-${algorithm}`]); + // Output: "sha256 Fingerprint=AB:CD:..." — extract the hex part and strip colons + const match = result.stdout.match(/Fingerprint=([A-F0-9:]+)/i); + if (!match) { + throw new Error(`Could not parse openssl fingerprint output: ${result.stdout}`); + } + return match[1].replace(/:/g, '').toLowerCase(); +} diff --git a/packages/esrp-npm-release/src/__tests__/ESRPReleaseService.test.ts b/packages/esrp-npm-release/src/__tests__/ESRPReleaseService.test.ts new file mode 100644 index 000000000..98bbab897 --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/ESRPReleaseService.test.ts @@ -0,0 +1,351 @@ +import type { BlobServiceClient } from '@azure/storage-blob'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import fs from 'fs'; +import jws from 'jws'; +import path from 'path'; +import type { CreateReleaseParams, ESRPReleaseService as ESRPReleaseServiceType } from '../ESRPReleaseService.ts'; +import { MockLogger } from '../__fixtures__/MockLogger.ts'; +import { + createMockBlobServiceClient, + createMockBlockBlobClient, + createMockContainerClient, + type MockBlobServiceClient, + type MockBlockBlobClient, + type MockContainerClient, +} from '../__fixtures__/mockAzure.ts'; +import { createMockEsrpHttp } from '../__fixtures__/mockEsrpHttp.ts'; +import { setupTempDir } from '../__fixtures__/tempDir.ts'; +import { generateTestCert, isOpensslAvailable, type TestCert } from '../__fixtures__/testCert.ts'; +import type * as getAadTokenModule from '../auth/getAadToken.ts'; +import { esrpApiEndpoint } from '../esrpApi/releaseHttp.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; + +const mockGetAadToken = jest.fn(); +jest.unstable_mockModule('../auth/getAadToken.ts', () => ({ + getAadToken: mockGetAadToken, +})); + +// Mock hashFileStream to avoid issues with fake timers (the hash isn't used) +jest.unstable_mockModule('../utils/hashFileStream.ts', () => ({ + hashFileStream: () => Promise.resolve(Buffer.from('fake')), +})); + +const mockEsrpHttp = createMockEsrpHttp(); +jest.unstable_mockModule('../esrpApi/releaseHttp.ts', () => mockEsrpHttp); + +// Late import after mocks are registered (required by jest.unstable_mockModule) +const { ESRPReleaseService } = await import('../ESRPReleaseService.ts'); + +// eslint-disable-next-line no-restricted-properties +const describeIfOpenssl = isOpensslAvailable() ? describe : describe.skip; + +describeIfOpenssl('ESRPReleaseService.createRelease', () => { + let testCert: TestCert; + let zipFilePath: string; + let logger: MockLogger; + let blobClient: MockBlockBlobClient; + let containerClient: MockContainerClient; + let blobServiceClient: MockBlobServiceClient; + let service: ESRPReleaseServiceType; + const blobUrl = 'https://stagingaccount.blob.core.windows.net/staging/r/op-1'; + const zipName = 'layer-01-123456789.zip'; + + // The staged "zip" file is a real file on disk for e2e testing + const fileDir = setupTempDir({ cleanup: 'afterAll' }); + + beforeAll(() => { + testCert = generateTestCert(); + zipFilePath = path.join(fileDir.getTempDir(), zipName); + fs.writeFileSync(zipFilePath, 'mock zip contents'); + }); + + beforeEach(async () => { + jest.useFakeTimers(); + // Default to a far-future expiry so polling doesn't trigger refresh; specific tests + // override this to exercise the refresh path. + mockGetAadToken.mockResolvedValue({ token: 'aad-token', expiresOnTimestamp: Date.now() + 24 * 60 * 60 * 1000 }); + mockEsrpHttp.submitRelease.mockResolvedValue({ operationId: 'mock-op-id' }); + mockEsrpHttp.getReleaseStatus.mockResolvedValue({ status: 'pass' }); + mockEsrpHttp.getReleaseDetails.mockResolvedValue({}); + logger = new MockLogger(); + blobClient = createMockBlockBlobClient(blobUrl); + containerClient = createMockContainerClient({ blobClient }); + blobServiceClient = createMockBlobServiceClient({ containerClient }); + + service = await ESRPReleaseService.create({ + logger, + clientId: 'cid', + tenantId: 'tid', + // Used only for getAadToken (which is mocked), so the value is never parsed + authCertificatePfx: 'auth-pfx-not-parsed', + // Real PFX -- parsed by getKeyAndCertificatesFromPFX in the constructor + requestSigningCertificatePfx: testCert.pfxBase64, + stagingBlobServiceClient: blobServiceClient as unknown as BlobServiceClient, + }); + logger.startGroup('layer-01', 'Releasing layer 01'); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + }); + + function releaseParams(overrides?: Partial): CreateReleaseParams { + return { + filePath: zipFilePath, + stagingBlobPathPrefix: 'repo1', + releaseRequestParams: { + createdBy: 'me@example.com', + driEmail: ['me@example.com'], + owners: ['me@example.com'], + approvers: ['me@example.com'], + productInfo: { name: 'TestProduct', version: 'abc-01', description: 'desc' }, + releaseTitle: 'TestProduct', + npmTag: undefined, + }, + ...overrides, + }; + } + + async function runCreateRelease(overrides?: Partial) { + const promise = service.createRelease(releaseParams(overrides)); + promise.catch(() => undefined); // suppress unhandled rejection while running timers + await jest.runAllTimersAsync(); + return promise; + } + + async function expectReleaseError(message: string, originalError?: Error) { + const err = await runCreateRelease().catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toContain(message); + if (originalError) { + expect((err as ReleaseError).cause).toBe(originalError); + } else { + expect((err as ReleaseError).cause).toBeUndefined(); + } + } + + it('acquires creds, uploads blob, signs+submits, polls until pass, deletes blob', async () => { + logger.addPath(fileDir.getTempDir(), ''); + + await runCreateRelease(); + + expect(mockGetAadToken).toHaveBeenCalledWith({ + scopes: [`${esrpApiEndpoint}.default`], + clientId: 'cid', + tenantId: 'tid', + auth: { certPfxContent: 'auth-pfx-not-parsed' }, + logger, + }); + expect(blobServiceClient.getUserDelegationKey).toHaveBeenCalledTimes(1); + expect(blobClient.uploadFile).toHaveBeenCalledWith(zipFilePath); + expect(mockEsrpHttp.submitRelease).toHaveBeenCalledTimes(1); + expect(mockEsrpHttp.getReleaseDetails).toHaveBeenCalledWith({ + clientId: 'cid', + bearerToken: 'aad-token', + releaseId: 'mock-op-id', + }); + expect(blobClient.delete).toHaveBeenCalledTimes(1); + + // One snapshot of output to verify it looks reasonable (remove large objects and UUIDs) + expect( + logger.lines + .map(line => line.replace(/\{[\s\S]*\}$/, '{ ... }')) + .map(line => line.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i, '')) + ).toMatchInlineSnapshot(` + [ + "[log] Getting client and ensuring staging container "staging" exists", + "[log] Extracting request signing key and certificates from PFX", + "[log] Found 2 certificate(s) in PFX; leaf is at index 0 (using as-is)", + "[log] ##[group]Releasing layer 01", + "[log] [layer-01] Acquiring fresh credentials for release", + "[log] [layer-01] Acquiring AAD access token for ESRP API at https://api.esrp.microsoft.com/", + "[log] [layer-01] Requesting user delegation key for staging storage account "mockaccount"", + "[log] [layer-01] Uploading /layer-01-123456789.zip to https://stagingaccount.blob.core.windows.net/staging/r/op-1", + "[log] [layer-01] Generating SAS token for staging blob "repo1/"", + "[log] [layer-01] Preparing to submit release", + "[log] [layer-01] Sending request to ESRP API: { ... }", + "[log] [layer-01] Successfully submitted release mock-op-id. Polling for completion...", + "[log] [layer-01] Release status: "pass"", + "[log] [layer-01] Release mock-op-id passed. Last status details: { ... }", + "[log] [layer-01] Release mock-op-id passed; fetching release details", + "[log] [layer-01] Release details: { ... }", + "[log] [layer-01] Deleting blob https://stagingaccount.blob.core.windows.net/staging/r/op-1", + ] + `); + }); + + it('submits a request whose file URLs include the SAS token suffix', async () => { + await runCreateRelease(); + + const { releaseRequest } = mockEsrpHttp.submitRelease.mock.calls[0][0]; + expect(releaseRequest.files).toHaveLength(1); + const file = releaseRequest.files![0]; + expect(file.name).toBe(zipName); + expect(file.tenantFileLocationType).toBe('AzureBlob'); + // The blob URL should be the staging URL + a SAS token query string ("?sv=...") + expect(file.tenantFileLocation).toContain(`${blobUrl}?`); + expect(file.sourceLocation.blobUrl).toBe(file.tenantFileLocation); + // SAS token shape: must include Azure SAS query parameters (just test one) + expect(file.tenantFileLocation).toMatch(/[?&]sig=/); + }); + + it('signs the request with a JWS verifiable against the configured signing cert', async () => { + await runCreateRelease(); + + const { releaseRequest } = mockEsrpHttp.submitRelease.mock.calls[0][0]; + expect(typeof releaseRequest.jwsToken).toBe('string'); + expect(releaseRequest.jwsToken).toMatch(/^[\w-]+\.[\w-]+\.[\w-]+$/); + expect(jws.verify(releaseRequest.jwsToken!, 'RS256', testCert.leafCertPem)).toBe(true); + }); + + // verifying current implementation (unclear if it's strictly needed) + it('re-acquires AAD and SAS credentials per createRelease call', async () => { + await runCreateRelease(); + await runCreateRelease(); + + expect(mockGetAadToken).toHaveBeenCalledTimes(2); + expect(blobServiceClient.getUserDelegationKey).toHaveBeenCalledTimes(2); + }); + + it('refreshes AAD access token during polling when near expiry', async () => { + // First token: near-expiry (refresh threshold is 5 min before expiry, so this already + // qualifies for refresh on the first poll). Second token: far-future. + mockGetAadToken + .mockResolvedValueOnce({ token: 'aad-token-1', expiresOnTimestamp: Date.now() + 10_000 }) + .mockResolvedValueOnce({ token: 'aad-token-2', expiresOnTimestamp: Date.now() + 24 * 60 * 60 * 1000 }); + mockEsrpHttp.queueStatuses(['inprogress', 'pass']); + + await runCreateRelease(); + + // Initial acquisition + one refresh during polling. + expect(mockGetAadToken).toHaveBeenCalledTimes(2); + // Submit happens before polling refresh, so it uses the original token; all status + // calls happen after the first refresh check, so they use the refreshed token. + expect(mockEsrpHttp.submitRelease.mock.calls[0][0].bearerToken).toBe('aad-token-1'); + const statusCalls = mockEsrpHttp.getReleaseStatus.mock.calls; + expect(statusCalls.every(c => c[0].bearerToken === 'aad-token-2')).toBe(true); + expect(logger.lines.some(l => l.includes('AAD access token near expiry, refreshing'))).toBe(true); + }); + + it('polls every 5 seconds', async () => { + mockEsrpHttp.queueStatuses(['inprogress', 'inprogress', 'pass']); + + const promise = service.createRelease(releaseParams()); + promise.catch(() => undefined); + await jest.advanceTimersByTimeAsync(5000); + expect(mockEsrpHttp.submitRelease).toHaveBeenCalledTimes(1); + expect(mockEsrpHttp.getReleaseStatus).toHaveBeenCalledTimes(1); + + // Each additional 5s should produce one more poll until 'pass'. + await jest.advanceTimersByTimeAsync(5000); + expect(mockEsrpHttp.getReleaseStatus).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(5000); + expect(mockEsrpHttp.getReleaseStatus).toHaveBeenCalledTimes(3); + + await jest.runAllTimersAsync(); + await promise; + }); + + it('logs status only when status changes (not every poll)', async () => { + mockEsrpHttp.queueStatuses(['inprogress', 'inprogress', 'inprogress', 'pass']); + + await runCreateRelease(); + + const statusLines = logger.lines.filter(l => l.includes('Release status:')); + expect(statusLines).toEqual([ + '[log] [layer-01] Release status: "inprogress"', + '[log] [layer-01] Release status: "pass"', + ]); + }); + + it.each([ + ['aborted', 'Release was aborted'], + ['cancelled', 'Release was aborted'], + ['unexpected', 'Unexpected release status "unexpected"'], + ] as const)('throws ReleaseError on "%s" status', async (status, messagePart) => { + mockEsrpHttp.queueStatuses([status]); + + await expectReleaseError(messagePart); + expect(blobClient.delete).toHaveBeenCalledTimes(1); + }); + + it('throws a custom auth-focused message for npm registry 404 errors', async () => { + mockEsrpHttp.getReleaseStatus.mockResolvedValue({ + // Based on an actual failure response + status: 'failDoNotRetry', + errorInfo: { + details: { + errors: '404 Not Found - PUT https://registry.npmjs.org/@microsoft%2fsome-lib - Not found', + }, + }, + }); + + const err = await runCreateRelease().catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + const message = (err as ReleaseError).message; + expect(message).toContain('Release failed with 404 on npm publish:'); + expect(message).toContain('Full status API response'); + expect(blobClient.delete).toHaveBeenCalledTimes(1); + }); + + it('throws ReleaseError after polling timeout (720 iterations of inprogress)', async () => { + mockEsrpHttp.getReleaseStatus.mockResolvedValue({ status: 'inprogress' }); + + await expectReleaseError('Timed out waiting for release. Most recent status'); + expect(mockEsrpHttp.getReleaseStatus).toHaveBeenCalledTimes(720); + }); + + it.each([ + ['submitRelease', 'Failed to submit release'], + ['getReleaseStatus', 'Failed to get release status'], + ] as const)('wraps %s failures with ReleaseError', async (method, message) => { + const originalError = new Error(`${method} failed`); + mockEsrpHttp[method].mockRejectedValue(originalError); + await expectReleaseError(message, originalError); + }); + + it('logs getReleaseDetails failure as a warning but does not fail the release', async () => { + mockEsrpHttp.getReleaseStatus.mockResolvedValue({ status: 'pass' }); + mockEsrpHttp.getReleaseDetails.mockRejectedValue(new Error('details failed')); + + await runCreateRelease(); // should not throw — packages were already published + expect(logger.lines.some(l => l.startsWith('[warn]') && l.includes('succeeded but fetching details failed'))).toBe( + true + ); + }); + + it('wraps SAS token generation failures with ReleaseError', async () => { + const originalError = new Error('sas failed'); + blobServiceClient.getUserDelegationKey.mockRejectedValue(originalError); + await expectReleaseError('Error generating SAS token', originalError); + }); + + it('wraps AAD token failures with ReleaseError', async () => { + const originalError = new Error('aad failed'); + mockGetAadToken.mockRejectedValue(originalError); + await expectReleaseError('Error acquiring access token for ESRP API', originalError); + }); + + it('wraps blob upload failures with ReleaseError', async () => { + const originalError = new Error('upload failed'); + blobClient.uploadFile.mockRejectedValue(originalError); + await expectReleaseError('Error uploading file to staging storage', originalError); + }); + + it('logs blob deletion failure as a warning but preserves the original outcome', async () => { + mockEsrpHttp.getReleaseStatus.mockResolvedValue({ status: 'pass' }); + blobClient.delete.mockRejectedValue(new Error('delete failed')); + + await runCreateRelease(); // should not throw + expect(logger.lines.some(l => l.startsWith('[warn]') && l.includes('Failed to delete blob'))).toBe(true); + }); + + it('preserves the original error when blob deletion also fails after a release error', async () => { + mockEsrpHttp.queueStatuses(['aborted']); + blobClient.delete.mockRejectedValue(new Error('delete failed')); + + await expectReleaseError('Release was aborted'); + expect(logger.lines.some(l => l.startsWith('[warn]') && l.includes('Failed to delete blob'))).toBe(true); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/ReleaseState.test.ts b/packages/esrp-npm-release/src/__tests__/ReleaseState.test.ts new file mode 100644 index 000000000..25574e213 --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/ReleaseState.test.ts @@ -0,0 +1,222 @@ +import type { BlobServiceClient } from '@azure/storage-blob'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { + createMockBlobServiceClient, + createMockBlockBlobClient, + createMockContainerClient, + type MockBlockBlobClient, +} from '../__fixtures__/mockAzure.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; +import { ReleaseState } from '../utils/ReleaseState.ts'; + +describe('ReleaseState', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('lists blobs with the expected prefix and populates publishedLayers', async () => { + const containerClient = createMockContainerClient({ + blobNames: ['repo1/abc/0', 'repo1/abc/1', 'repo1/abc/2'], + }); + const blobServiceClient = createMockBlobServiceClient({ containerClient }); + + const state = await ReleaseState.create({ + blobServiceClient: blobServiceClient as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }); + + expect(blobServiceClient.getContainerClient).toHaveBeenCalledWith('release-state'); + expect(containerClient.createIfNotExists).toHaveBeenCalledTimes(1); + expect(containerClient.listBlobsFlat).toHaveBeenCalledWith({ prefix: 'repo1/abc/' }); + expect(state.publishedCount).toBe(3); + expect(state.hasPublished('2')).toBe(true); + expect(state.hasPublished('3')).toBe(false); + }); + + it('returns publishedCount=0 when no blobs match the prefix', async () => { + const containerClient = createMockContainerClient({ blobNames: [] }); + const state = await ReleaseState.create({ + blobServiceClient: createMockBlobServiceClient({ containerClient }) as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }); + + expect(state.publishedCount).toBe(0); + }); + + it('wraps errors from getContainerClient with ReleaseError', async () => { + const blobServiceClient = createMockBlobServiceClient(); + const originalError = new Error('synthetic getContainerClient failure'); + blobServiceClient.getContainerClient.mockImplementation(() => { + throw originalError; + }); + + const err = await ReleaseState.create({ + blobServiceClient: blobServiceClient as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }).catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe( + `Error initializing client for container "release-state" in storage account "mockaccount"` + ); + expect((err as ReleaseError).cause).toBe(originalError); + }); + + it('wraps errors from createIfNotExists with ReleaseError mentioning the container and account', async () => { + const containerClient = createMockContainerClient({ accountName: 'myacct' }); + const originalError = new Error('synthetic createIfNotExists failure'); + containerClient.createIfNotExists.mockRejectedValue(originalError); + + const err = await ReleaseState.create({ + blobServiceClient: createMockBlobServiceClient({ + accountName: 'myacct', + containerClient, + }) as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }).catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe( + 'Error creating or accessing container "release-state" in storage account "myacct"' + ); + expect((err as ReleaseError).cause).toBe(originalError); + }); + + it('wraps errors from listBlobsFlat with ReleaseError mentioning the prefix', async () => { + const containerClient = createMockContainerClient(); + const originalError = new Error('synthetic listBlobsFlat failure'); + containerClient.listBlobsFlat.mockImplementation(() => { + throw originalError; + }); + + const err = await ReleaseState.create({ + blobServiceClient: createMockBlobServiceClient({ containerClient }) as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }).catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe( + 'Error listing blobs with prefix "repo1/abc/" in container "release-state" in storage account "mockaccount"' + ); + expect((err as ReleaseError).cause).toBe(originalError); + }); + }); + + describe('markPublished', () => { + async function newState() { + const blobClient = createMockBlockBlobClient(); + const containerClient = createMockContainerClient({ blobClient }); + const blobServiceClient = createMockBlobServiceClient({ containerClient }); + const state = await ReleaseState.create({ + blobServiceClient: blobServiceClient as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }); + return { state, blobClient, containerClient }; + } + + it('uploads an empty blob at the prefixed path and adds the layer to the set', async () => { + const { state, blobClient, containerClient } = await newState(); + + await state.markPublished('5'); + + expect(containerClient.getBlockBlobClient).toHaveBeenCalledWith('repo1/abc/5'); + expect(blobClient.upload).toHaveBeenCalledWith('', 0); + expect(state.hasPublished('5')).toBe(true); + expect(state.publishedCount).toBe(1); + }); + + it('wraps upload failures with ReleaseError mentioning the layer and account', async () => { + const blobClient = createMockBlockBlobClient(); + const originalError = new Error('synthetic upload failure'); + blobClient.upload.mockRejectedValue(originalError); + const containerClient = createMockContainerClient({ accountName: 'myacct', blobClient }); + const state = await ReleaseState.create({ + blobServiceClient: createMockBlobServiceClient({ + accountName: 'myacct', + containerClient, + }) as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }); + + const err = await state.markPublished('5').catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe( + 'Error marking layer 5 as published in persisted release state (container "release-state" in storage account "myacct")' + ); + expect((err as ReleaseError).cause).toBe(originalError); + // Did not record the layer as published since upload failed + expect(state.hasPublished('5')).toBe(false); + }); + }); + + describe('persistence round-trip', () => { + it('a layer marked published shows up in a fresh ReleaseState.create against the same container', async () => { + // Stateful container: getBlockBlobClient(name) returns per-name clients and + // listBlobsFlat reflects whatever has been uploaded. + const containerClient = createMockContainerClient(); + const blobServiceClient = createMockBlobServiceClient({ containerClient }); + + const initial = await ReleaseState.create({ + blobServiceClient: blobServiceClient as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }); + expect(initial.publishedCount).toBe(0); + + await initial.markPublished('0'); + await initial.markPublished('2'); + + // The blob clients are per-name, so each upload was against the right blob. + const blob0 = containerClient.getBlockBlobClient.mock.results[0].value as MockBlockBlobClient; + const blob2 = containerClient.getBlockBlobClient.mock.results[1].value as MockBlockBlobClient; + expect(blob0.url).toBe('https://mockaccount.blob.core.windows.net/mockcontainer/repo1/abc/0'); + expect(blob2.url).toBe('https://mockaccount.blob.core.windows.net/mockcontainer/repo1/abc/2'); + expect(blob0.upload).toHaveBeenCalledWith('', 0); + expect(blob2.upload).toHaveBeenCalledWith('', 0); + + // A fresh ReleaseState reading the same container should see both layers. + const reloaded = await ReleaseState.create({ + blobServiceClient: blobServiceClient as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }); + expect(reloaded.publishedCount).toBe(2); + expect(reloaded.hasPublished('0')).toBe(true); + expect(reloaded.hasPublished('2')).toBe(true); + expect(reloaded.hasPublished('1')).toBe(false); + }); + + it('isolates state by repoName and sourceVersion prefix', async () => { + const containerClient = createMockContainerClient(); + const blobServiceClient = createMockBlobServiceClient({ containerClient }); + + const repo1 = await ReleaseState.create({ + blobServiceClient: blobServiceClient as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'abc', + }); + await repo1.markPublished('0'); + + // Different repoName -- should see nothing + const repo2 = await ReleaseState.create({ + blobServiceClient: blobServiceClient as unknown as BlobServiceClient, + repoName: 'repo2', + sourceVersion: 'abc', + }); + expect(repo2.publishedCount).toBe(0); + + // Different sourceVersion -- should see nothing + const repo1Other = await ReleaseState.create({ + blobServiceClient: blobServiceClient as unknown as BlobServiceClient, + repoName: 'repo1', + sourceVersion: 'def', + }); + expect(repo1Other.publishedCount).toBe(0); + }); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/generateJwsToken.test.ts b/packages/esrp-npm-release/src/__tests__/generateJwsToken.test.ts new file mode 100644 index 000000000..464fde79e --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/generateJwsToken.test.ts @@ -0,0 +1,101 @@ +import { beforeAll, describe, expect, it } from '@jest/globals'; +import jws from 'jws'; +import { generateTestCert, isOpensslAvailable, type TestCert } from '../__fixtures__/testCert.ts'; +import { generateJwsToken } from '../auth/generateJwsToken.ts'; + +// eslint-disable-next-line no-restricted-properties -- intentional skip when openssl is unavailable +const describeIfOpenssl = isOpensslAvailable() ? describe : describe.skip; + +describeIfOpenssl('generateJwsToken', () => { + let testCert: TestCert; + + beforeAll(() => { + testCert = generateTestCert(); + }); + + /** `jws.decode` returns `null | undefined` for invalid tokens; throw to keep test types simple. */ + function decodeOrThrow(token: string): jws.Signature { + const decoded = jws.decode(token); + if (!decoded) throw new Error('Could not decode JWS token'); + return decoded; + } + + function makeToken(): string { + return generateJwsToken({ + releaseRequest: { driEmail: ['dri@example.com'] }, + certificates: [testCert.leafCertPem], + privateKey: testCert.keyPem, + }); + } + + it('produces a JWS that verifies against the leaf cert public key', () => { + const token = makeToken(); + expect(jws.verify(token, 'RS256', testCert.leafCertPem)).toBe(true); + }); + + it("includes the leaf cert's hex SHA1 thumbprint as x5t", () => { + const decoded = decodeOrThrow(makeToken()); + expect((decoded.header as Record).x5t).toBe(testCert.sha1ThumbprintHex); + }); + + it('includes the certificate chain as a "."-separated x5c (non-standard ESRP format)', () => { + const decoded = decodeOrThrow(makeToken()); + const x5c = (decoded.header as Record).x5c as string; + expect(typeof x5c).toBe('string'); + // Single-cert chain so no separator should appear + expect(x5c.includes('.')).toBe(false); + // The base64url-decoded value matches the leaf cert DER + const der = Buffer.from(x5c, 'base64url').toString('hex'); + const certDer = Buffer.from( + testCert.leafCertPem.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/g, ''), + 'base64' + ).toString('hex'); + expect(der).toBe(certDer); + }); + + it('joins multi-certificate chains with "." in leaf-then-CA order', () => { + const token = generateJwsToken({ + releaseRequest: { driEmail: ['test@example.com'] }, + certificates: [testCert.leafCertPem, testCert.caCertPem], + privateKey: testCert.keyPem, + }); + const decoded = decodeOrThrow(token); + const x5c = (decoded.header as Record).x5c as string; + const parts = x5c.split('.'); + expect(parts).toHaveLength(2); + + const leafDer = Buffer.from(parts[0], 'base64url').toString('hex'); + const caDer = Buffer.from(parts[1], 'base64url').toString('hex'); + const expectedLeafDer = Buffer.from( + testCert.leafCertPem.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/g, ''), + 'base64' + ).toString('hex'); + const expectedCaDer = Buffer.from( + testCert.caCertPem.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/g, ''), + 'base64' + ).toString('hex'); + expect(leafDer).toBe(expectedLeafDer); + expect(caDer).toBe(expectedCaDer); + }); + + it('sets exp using .NET ticks (greater than Date.now() in milliseconds)', () => { + const now = Date.now(); + const decoded = decodeOrThrow(makeToken()); + const exp = (decoded.header as Record).exp as number; + // .NET ticks since 1/1/0001, which is far larger than any reasonable ms-since-epoch. + expect(exp).toBeGreaterThan(now); + expect(exp).toBeGreaterThan(621355968000000000); + }); + + it('serializes the release request as the JWS payload', () => { + const releaseRequest = { driEmail: ['custom@test.com'] }; + const token = generateJwsToken({ + releaseRequest, + certificates: [testCert.leafCertPem], + privateKey: testCert.keyPem, + }); + const decoded = decodeOrThrow(token); + // jws decodes JSON payloads automatically when the header alg is RS256 + expect(JSON.parse(decoded.payload as string)).toEqual(releaseRequest); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/getAadToken.test.ts b/packages/esrp-npm-release/src/__tests__/getAadToken.test.ts new file mode 100644 index 000000000..f0f817fb2 --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/getAadToken.test.ts @@ -0,0 +1,166 @@ +import type { AuthenticationResult, ConfidentialClientApplication, NodeAuthOptions } from '@azure/msal-node'; +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { MockLogger } from '../__fixtures__/MockLogger.ts'; +import { generateTestCert, isOpensslAvailable, type TestCert } from '../__fixtures__/testCert.ts'; +import type { GetAadTokenParams } from '../auth/getAadToken.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; + +let lastAuthOptions: NodeAuthOptions | undefined; +const acquireTokenByClientCredential = jest.fn(); + +jest.unstable_mockModule('@azure/msal-node', () => ({ + ConfidentialClientApplication: jest.fn((opts: { auth: NodeAuthOptions }) => { + lastAuthOptions = opts.auth; + return { acquireTokenByClientCredential }; + }), +})); + +const { getAadToken } = await import('../auth/getAadToken.ts'); + +// eslint-disable-next-line no-restricted-properties +const describeIfOpenssl = isOpensslAvailable() ? describe : describe.skip; + +describe('getAadToken', () => { + let logger: MockLogger; + + const scopes = ['https://sample.microsoft.com/.default']; + const baseParams: Pick = { + clientId: 'client-id', + tenantId: 'tenant-id', + scopes, + }; + + function makeAuthResult(overrides: Partial = {}): AuthenticationResult { + return { + accessToken: 'access-token', + expiresOn: new Date('2099-01-01T00:00:00Z'), + ...overrides, + } as AuthenticationResult; + } + + beforeEach(() => { + acquireTokenByClientCredential.mockReset(); + lastAuthOptions = undefined; + logger = new MockLogger(); + }); + + describe('idToken (federated) auth', () => { + it('passes the idToken as clientAssertion and acquires the token with the correct scope', async () => { + acquireTokenByClientCredential.mockResolvedValue(makeAuthResult()); + + const result = await getAadToken({ + ...baseParams, + auth: { idToken: 'federated-id-token' }, + logger, + }); + + expect(result).toEqual({ + token: 'access-token', + expiresOnTimestamp: new Date('2099-01-01T00:00:00Z').getTime(), + refreshAfterTimestamp: undefined, + }); + expect(lastAuthOptions).toEqual({ + clientId: 'client-id', + authority: 'https://login.microsoftonline.com/tenant-id', + clientAssertion: 'federated-id-token', + }); + expect(acquireTokenByClientCredential).toHaveBeenCalledWith({ scopes }); + }); + + it('forwards refreshAfterTimestamp when MSAL returns refreshOn', async () => { + const refreshOn = new Date('2099-01-01T00:30:00Z'); + acquireTokenByClientCredential.mockResolvedValue(makeAuthResult({ refreshOn })); + + const result = await getAadToken({ + ...baseParams, + auth: { idToken: 'tok' }, + logger, + }); + + expect(result.refreshAfterTimestamp).toBe(refreshOn.getTime()); + }); + }); + + describeIfOpenssl('certificate (client-credentials) auth', () => { + let testCert: TestCert; + + beforeAll(() => { + testCert = generateTestCert(); + }); + + it('extracts the leaf cert and key from the PFX and passes them as clientCertificate', async () => { + acquireTokenByClientCredential.mockResolvedValue(makeAuthResult()); + + await getAadToken({ + ...baseParams, + auth: { certPfxContent: testCert.pfxBase64 }, + logger, + }); + + expect(lastAuthOptions).toEqual({ + clientId: 'client-id', + authority: 'https://login.microsoftonline.com/tenant-id', + clientCertificate: { + // Independently-computed thumbprint of the leaf cert from testCert + thumbprintSha256: testCert.sha256ThumbprintHex, + privateKey: expect.stringMatching(/^-----BEGIN PRIVATE KEY-----[\s\S]+-----END PRIVATE KEY-----$/), + // signing.ts extracts certs via regex, so any trailing newline from openssl is stripped + x5c: testCert.leafCertPem.trimEnd(), + }, + }); + }); + + it('wraps PFX-parsing errors with a "parsing cert info" ReleaseError', async () => { + const err = await getAadToken({ + ...baseParams, + auth: { certPfxContent: 'not-a-real-pfx' }, + logger, + }).catch(e => e as unknown); + + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toContain('Error parsing cert info to acquire token'); + expect(acquireTokenByClientCredential).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('wraps acquireTokenByClientCredential failures with ReleaseError preserving the cause', async () => { + const originalError = new Error('oh no'); + acquireTokenByClientCredential.mockRejectedValue(originalError); + + const err = await getAadToken({ + ...baseParams, + auth: { idToken: 'tok' }, + logger, + }).catch(e => e as unknown); + + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toEqual( + `Failed to acquire token for client "client-id" in tenant "tenant-id" with scope ${JSON.stringify(scopes)}` + ); + expect((err as ReleaseError).cause).toBe(originalError); + }); + + it('throws ReleaseError when MSAL returns null (no token)', async () => { + acquireTokenByClientCredential.mockResolvedValue(null); + + const err = await getAadToken({ ...baseParams, auth: { idToken: 'tok' }, logger }).catch(e => e as unknown); + + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toContain('no result returned'); + }); + + it('throws ReleaseError when MSAL returns a result without expiresOn', async () => { + acquireTokenByClientCredential.mockResolvedValue({ accessToken: 'tok' } as AuthenticationResult); + + const err = await getAadToken({ + ...baseParams, + auth: { idToken: 'tok' }, + logger, + }).catch(e => e as unknown); + + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toContain('no result returned'); + }); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/getEnvOptions.test.ts b/packages/esrp-npm-release/src/__tests__/getEnvOptions.test.ts new file mode 100644 index 000000000..2e4b331d7 --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/getEnvOptions.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from '@jest/globals'; +import { createMockProcessEnv } from '../__fixtures__/mockEnv.ts'; +import { getEnvOptions } from '../utils/getEnvOptions.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; + +describe('getEnvOptions', () => { + it('returns a fully-populated EnvOptions object when all required vars are set', () => { + const env = getEnvOptions(createMockProcessEnv()); + + expect(env).toEqual({ + packedPackagesPath: '/tmp/packed', + esrp: { + productName: 'TestProduct', + npmTag: undefined, + createdBy: 'test@example.com', + driEmail: ['test@example.com'], + owners: ['test@example.com'], + approvers: ['test@example.com'], + tenantId: 'esrp-tenant', + clientId: 'esrp-client', + authCertificatePfx: 'mock-auth-pfx', + requestSigningCertificatePfx: 'mock-signing-pfx', + }, + staging: { + storageAccountName: 'stagingaccount', + clientId: 'staging-client', + idToken: 'staging-id-token', + tenantId: 'staging-tenant', + }, + ado: { + agentTempDirectory: '/tmp/agent', + buildSourceVersion: 'abcdef0123456789', + buildRepositoryName: 'org/repo', + }, + }); + }); + + it('uses ESRP_USER as fallback for createdBy/driEmail/owners/approvers when those are unset', () => { + const env = getEnvOptions( + createMockProcessEnv({ + ESRP_USER: 'fallback@example.com', + ESRP_CREATED_BY: undefined, + ESRP_DRI_EMAIL: undefined, + ESRP_OWNERS: undefined, + ESRP_APPROVERS: undefined, + }) + ); + + expect(env.esrp.createdBy).toBe('fallback@example.com'); + expect(env.esrp.driEmail).toEqual(['fallback@example.com']); + expect(env.esrp.owners).toEqual(['fallback@example.com']); + expect(env.esrp.approvers).toEqual(['fallback@example.com']); + }); + + it('prefers explicit values over ESRP_USER fallback', () => { + const env = getEnvOptions( + createMockProcessEnv({ + ESRP_USER: 'fallback@example.com', + ESRP_CREATED_BY: 'creator@example.com', + ESRP_OWNERS: 'a@example.com,b@example.com', + ESRP_APPROVERS: 'c@example.com,d@example.com', + }) + ); + + expect(env.esrp.createdBy).toBe('creator@example.com'); + expect(env.esrp.owners).toEqual(['a@example.com', 'b@example.com']); + expect(env.esrp.approvers).toEqual(['c@example.com', 'd@example.com']); + }); + + it('treats ESRP_NPM_TAG="" as undefined', () => { + const env = getEnvOptions(createMockProcessEnv({ ESRP_NPM_TAG: '' })); + expect(env.esrp.npmTag).toBeUndefined(); + }); + + it('passes ESRP_NPM_TAG through when set', () => { + const env = getEnvOptions(createMockProcessEnv({ ESRP_NPM_TAG: 'beta' })); + expect(env.esrp.npmTag).toBe('beta'); + }); + + it('throws ReleaseError listing all missing required env vars', () => { + const baseEnv = createMockProcessEnv(); + delete baseEnv.PACKED_PACKAGES_PATH; + delete baseEnv.ESRP_PRODUCT_NAME; + delete baseEnv.STAGING_TENANT_ID; + + let err: unknown; + try { + getEnvOptions(baseEnv); + } catch (e) { + err = e; + } + + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toContain('PACKED_PACKAGES_PATH'); + expect((err as ReleaseError).message).toContain('ESRP_PRODUCT_NAME'); + expect((err as ReleaseError).message).toContain('STAGING_TENANT_ID'); + }); + + it('requires no ESRP_USER when individual user fields are all set', () => { + const env = createMockProcessEnv({ + ESRP_USER: undefined, + ESRP_CREATED_BY: 'a@example.com', + ESRP_DRI_EMAIL: 'b@example.com', + ESRP_OWNERS: 'c@example.com', + ESRP_APPROVERS: 'd@example.com', + }); + expect(() => getEnvOptions(env)).not.toThrow(); + }); + + it('throws when ESRP_USER and individual user fields are all unset', () => { + const env = createMockProcessEnv({ + ESRP_USER: undefined, + ESRP_CREATED_BY: undefined, + ESRP_DRI_EMAIL: undefined, + ESRP_OWNERS: undefined, + ESRP_APPROVERS: undefined, + }); + + let err: unknown; + try { + getEnvOptions(env); + } catch (e) { + err = e; + } + + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toContain('ESRP_CREATED_BY, ESRP_DRI_EMAIL, ESRP_OWNERS, ESRP_APPROVERS'); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/hashFileStream.test.ts b/packages/esrp-npm-release/src/__tests__/hashFileStream.test.ts new file mode 100644 index 000000000..9a9e9d87d --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/hashFileStream.test.ts @@ -0,0 +1,35 @@ +import { beforeAll, describe, expect, it } from '@jest/globals'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { setupTempDir } from '../__fixtures__/tempDir.ts'; +import { hashFileStream } from '../utils/hashFileStream.ts'; + +describe('hashFileStream', () => { + const dir = setupTempDir({ cleanup: 'afterAll' }); + let tempDir: string; + + beforeAll(() => { + tempDir = dir.getTempDir(); + }); + + it('matches crypto.createHash on the same content', async () => { + const content = Buffer.from('the quick brown fox jumps over the lazy dog\n'); + const file = path.join(tempDir, 'small.txt'); + fs.writeFileSync(file, content); + + const expected = crypto.createHash('sha256').update(content).digest(); + const actual = await hashFileStream('sha256', file); + expect(actual.toString('hex')).toBe(expected.toString('hex')); + }); + + it('handles a larger file via streaming', async () => { + const content = crypto.randomBytes(2 * 1024 * 1024); // 2 MiB + const file = path.join(tempDir, 'big.bin'); + fs.writeFileSync(file, content); + + const expected = crypto.createHash('sha256').update(content).digest(); + const actual = await hashFileStream('sha256', file); + expect(actual.toString('hex')).toBe(expected.toString('hex')); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/npmRelease.test.ts b/packages/esrp-npm-release/src/__tests__/npmRelease.test.ts new file mode 100644 index 000000000..6ae94af7f --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/npmRelease.test.ts @@ -0,0 +1,169 @@ +import { beforeAll, describe, expect, it } from '@jest/globals'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { setupTempDir } from '../__fixtures__/tempDir.ts'; +import { generateTestCert, isOpensslAvailable, type TestCert } from '../__fixtures__/testCert.ts'; +import { createNpmReleaseRequest, redactReleaseRequest } from '../esrpApi/npmRelease.ts'; +import { FileHashType, type ReleaseFileInfo, type ReleaseRequestMessage } from '../types/api.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; + +// eslint-disable-next-line no-restricted-properties -- intentional skip when openssl is unavailable +const describeIfOpenssl = isOpensslAvailable() ? describe : describe.skip; + +describe('redactReleaseRequest', () => { + const blobUrl = 'https://acct.blob.core.windows.net/staging/someblob'; + const blobSasUrl = `${blobUrl}?sv=2021-01-01&sig=SECRET&se=2099-01-01T00:00:00Z`; + + /** Build a minimal ReleaseRequestMessage shape with potentially-sensitive fields populated. */ + function makeMessage(): ReleaseRequestMessage { + return { + driEmail: ['dri@example.com'], + jwsToken: 'abc123.payload.signature', + files: [ + { name: 'pkg.tgz', tenantFileLocation: blobSasUrl, sourceLocation: { type: 'azureBlob', blobUrl: blobSasUrl } }, + ] as ReleaseFileInfo[], + }; + } + + it('replaces the JWS token with "***"', () => { + const redacted = redactReleaseRequest(makeMessage()); + expect(redacted.jwsToken).toBe('***'); + }); + + it('strips SAS query strings from tenantFileLocation and sourceLocation.blobUrl', () => { + const redacted = redactReleaseRequest(makeMessage()); + const file = redacted.files![0]; + expect(file.tenantFileLocation).toBe(`${blobUrl}?***`); + expect(file.sourceLocation.blobUrl).toBe(`${blobUrl}?***`); + }); + + it('does not mutate the original message (uses structuredClone)', () => { + const original = Object.freeze(makeMessage()); + const originalJws = original.jwsToken; + const originalLocation = original.files![0].tenantFileLocation; + + redactReleaseRequest(original); + + expect(original.jwsToken).toBe(originalJws); + expect(original.files![0].tenantFileLocation).toBe(originalLocation); + expect(original.files![0].tenantFileLocation).toContain('SECRET'); + }); + + it('preserves non-sensitive fields untouched', () => { + const redacted = redactReleaseRequest(makeMessage()); + expect(redacted.driEmail).toEqual(['dri@example.com']); + expect(redacted.files![0].name).toBe('pkg.tgz'); + }); + + it('leaves a tenantFileLocation without a query string unchanged', () => { + const msg = makeMessage(); + msg.files![0].tenantFileLocation = blobUrl; + msg.files![0].sourceLocation.blobUrl = blobUrl; + + const redacted = redactReleaseRequest(msg); + expect(redacted.files![0].tenantFileLocation).toBe(blobUrl); + expect(redacted.files![0].sourceLocation.blobUrl).toBe(blobUrl); + }); +}); + +describeIfOpenssl('createNpmReleaseRequest', () => { + let testCert: TestCert; + const fileDir = setupTempDir({ cleanup: 'afterAll' }); + + beforeAll(() => { + testCert = generateTestCert(); + }); + + function makeFile(name: string, contents: string): string { + const fullPath = path.join(fileDir.getTempDir(), name); + fs.writeFileSync(fullPath, contents); + return fullPath; + } + + function baseParams(filePath: string) { + return { + correlationId: 'corr-1', + driEmail: ['dri@example.com'], + createdBy: 'creator@example.com', + owners: ['owner1@example.com', 'owner2@example.com'], + approvers: ['approver@example.com'], + releaseTitle: 'TestProduct', + productInfo: { name: 'TestProduct', version: 'commit-0', description: 'desc' }, + file: { path: filePath, sasBlobUrl: 'https://acct.blob.core.windows.net/staging/key?sig=SECRET' }, + requestSigningCertificates: [testCert.leafCertPem, testCert.caCertPem], + requestSigningKey: testCert.keyPem, + }; + } + + it('builds a request with the expected shape and JWS token', async () => { + const filePath = makeFile('pkg.tgz', 'hello world'); + const params = baseParams(filePath); + + const result = await createNpmReleaseRequest(params); + + // The full message shape is part of the contract -- pin it in one assertion so any + // accidental change to default values (e.g. mainPublisher, IsRsm, contentType) is caught. + expect(result).toEqual({ + esrpCorrelationId: 'corr-1', + customerCorrelationId: 'corr-1', + driEmail: ['dri@example.com'], + createdBy: { userPrincipalName: 'creator@example.com' }, + owners: [ + { owner: { userPrincipalName: 'owner1@example.com' } }, + { owner: { userPrincipalName: 'owner2@example.com' } }, + ], + approvers: [ + { approver: { userPrincipalName: 'approver@example.com' }, isAutoApproved: true, isMandatory: false }, + ], + accessPermissionsInfo: { mainPublisher: 'ESRPRELPACMAN' }, + productInfo: { name: 'TestProduct', version: 'commit-0', description: 'desc' }, + releaseInfo: { + title: 'TestProduct', + minimumNumberOfApprovers: 1, + isRevision: false, + properties: { ReleaseContentType: 'npm', IsRsm: 'false' }, + }, + // No npmTag → no productState + routingInfo: { intent: 'packagedistribution', contentType: 'npm' }, + files: [ + { + name: 'pkg.tgz', + tenantFileLocation: params.file.sasBlobUrl, + tenantFileLocationType: 'AzureBlob', + sourceLocation: { type: 'azureBlob', blobUrl: params.file.sasBlobUrl }, + hashType: FileHashType.sha256, + hash: Array.from(crypto.createHash('sha256').update('hello world').digest()), + sizeInBytes: 11, + }, + ], + jwsToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/), + }); + }); + + it('includes productState when npmTag is provided', async () => { + const filePath = makeFile('tagged.tgz', 'x'); + const result = await createNpmReleaseRequest({ ...baseParams(filePath), npmTag: 'beta' }); + + expect(result.routingInfo).toEqual({ + intent: 'packagedistribution', + contentType: 'npm', + productState: 'beta', + }); + }); + + it('omits productState when npmTag is empty string', async () => { + const filePath = makeFile('untagged.tgz', 'x'); + const result = await createNpmReleaseRequest({ ...baseParams(filePath), npmTag: '' }); + + expect(result.routingInfo).toEqual({ intent: 'packagedistribution', contentType: 'npm' }); + }); + + it('throws ReleaseError when the file does not exist', async () => { + const params = baseParams('/no/such/file/exists.tgz'); + const err = await createNpmReleaseRequest(params).catch(e => e as unknown); + + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toContain('Failed to stat or hash file /no/such/file/exists.tgz'); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/releaseHttp.test.ts b/packages/esrp-npm-release/src/__tests__/releaseHttp.test.ts new file mode 100644 index 000000000..db461af21 --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/releaseHttp.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { getReleaseDetails, getReleaseStatus, submitRelease } from '../esrpApi/releaseHttp.ts'; +import type { ReleaseRequestMessage } from '../types/api.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; + +describe('releaseHttp', () => { + let fetchMock: jest.Mock; + const originalFetch = globalThis.fetch; + + const baseUrl = 'https://api.esrp.microsoft.com/api/v3/releaseservices/clients/'; + + const mockRequest = { driEmail: ['example@example.com'] } as ReleaseRequestMessage; + const defaultParams = { clientId: 'cid', bearerToken: 'tok' }; + const defaultGetParams = { ...defaultParams, releaseId: 'rid' }; + + function makeFetchResponse(opts: { status?: number; ok?: boolean; body: string }): Response { + const status = opts.status ?? 200; + const ok = opts.ok ?? (status >= 200 && status < 300); + return { + ok, + status, + text: () => Promise.resolve(opts.body), + } as Response; + } + + beforeEach(() => { + fetchMock = jest.fn(); + globalThis.fetch = fetchMock; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.useRealTimers(); + }); + + describe('submitRelease', () => { + it('POSTs to operations endpoint with content-type, JSON body, and parses response', async () => { + fetchMock.mockResolvedValue(makeFetchResponse({ body: '{"operationId":"op-1"}' })); + + const result = await submitRelease({ ...defaultParams, releaseRequest: mockRequest }); + + expect(result).toEqual({ operationId: 'op-1' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}cid/workflows/release/operations`, { + method: 'POST', + headers: { Authorization: 'Bearer tok', 'Content-Type': 'application/json' }, + body: JSON.stringify(mockRequest), + signal: expect.anything(), + }); + }); + }); + + describe('getReleaseStatus', () => { + it('GETs the grs endpoint and parses response', async () => { + fetchMock.mockResolvedValue(makeFetchResponse({ body: '{"status":"pass"}' })); + + const result = await getReleaseStatus(defaultGetParams); + + expect(result).toEqual({ status: 'pass' }); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}cid/workflows/release/operations/grs/rid`, { + method: 'GET', + headers: { Authorization: 'Bearer tok' }, + signal: expect.anything(), + }); + }); + }); + + describe('getReleaseDetails', () => { + it('GETs the grd endpoint and parses response', async () => { + fetchMock.mockResolvedValue(makeFetchResponse({ body: '{"foo":"bar"}' })); + + const result = await getReleaseDetails(defaultGetParams); + + expect(result).toEqual({ foo: 'bar' }); + expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}cid/workflows/release/operations/grd/rid`, { + method: 'GET', + headers: { Authorization: 'Bearer tok' }, + signal: expect.anything(), + }); + }); + }); + + describe('error handling', () => { + it('throws immediately on non-transient HTTP status, including status and body in message', async () => { + fetchMock.mockResolvedValue(makeFetchResponse({ status: 403, body: 'auth error' })); + + const err = await getReleaseStatus(defaultGetParams).catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe('Failed to get release status'); + expect(((err as ReleaseError).cause as Error).message).toMatch(/failed with status 403[\s\S]*auth error/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('retries on transient HTTP status and eventually succeeds', async () => { + jest.useFakeTimers(); + fetchMock + .mockResolvedValueOnce(makeFetchResponse({ status: 503, body: 'unavailable' })) + .mockResolvedValueOnce(makeFetchResponse({ status: 429, body: 'slow down' })) + .mockResolvedValueOnce(makeFetchResponse({ body: '{"status":"pass"}' })); + + const promise = getReleaseStatus(defaultGetParams); + await jest.runAllTimersAsync(); + + await expect(promise).resolves.toEqual({ status: 'pass' }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('throws after exhausting retries on transient HTTP status', async () => { + jest.useFakeTimers(); + fetchMock.mockResolvedValue(makeFetchResponse({ status: 500, body: 'internal server error' })); + + const promise = getReleaseStatus(defaultGetParams).catch(e => e as unknown); + await jest.runAllTimersAsync(); + + const err = await promise; + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe('Failed to get release status'); + expect(((err as ReleaseError).cause as Error).message).toMatch( + /failed after 10 attempts[\s\S]*status 500[\s\S]*internal server error/ + ); + expect(fetchMock).toHaveBeenCalledTimes(10); + }); + + it('throws when response body is not valid JSON', async () => { + fetchMock.mockResolvedValue(makeFetchResponse({ body: 'not json' })); + + const err = await getReleaseStatus(defaultGetParams).catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe('Failed to get release status'); + expect(((err as ReleaseError).cause as Error).message).toMatch(/did not return valid JSON[\s\S]*not json/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('retries on retryable network errors and eventually succeeds', async () => { + jest.useFakeTimers(); + fetchMock + .mockRejectedValueOnce(new Error('fetch failed')) + .mockRejectedValueOnce(new Error('socket hang up')) + .mockResolvedValueOnce(makeFetchResponse({ body: '{"status":"pass"}' })); + + const promise = getReleaseStatus(defaultGetParams); + await jest.runAllTimersAsync(); + + await expect(promise).resolves.toEqual({ status: 'pass' }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('throws after exhausting retries', async () => { + jest.useFakeTimers(); + fetchMock.mockRejectedValue(new Error('fetch failed')); + + const promise = getReleaseStatus(defaultGetParams).catch(e => e as unknown); + await jest.runAllTimersAsync(); + + const err = await promise; + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe('Failed to get release status'); + expect(((err as ReleaseError).cause as Error).message).toMatch(/failed after 10 attempts[\s\S]*fetch failed/); + expect(fetchMock).toHaveBeenCalledTimes(10); + }); + + it('throws immediately on non-retryable errors without retrying', async () => { + fetchMock.mockRejectedValue(new Error('noooooooooooooo')); + + const err = await getReleaseStatus(defaultGetParams).catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toBe('Failed to get release status'); + expect(((err as ReleaseError).cause as Error).message).toMatch(/noooooooooooooo/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/runRelease.test.ts b/packages/esrp-npm-release/src/__tests__/runRelease.test.ts new file mode 100644 index 000000000..1d08872b6 --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/runRelease.test.ts @@ -0,0 +1,228 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; +import type { ESRPReleaseService } from '../ESRPReleaseService.ts'; +import { MockLogger } from '../__fixtures__/MockLogger.ts'; +import { createMockEnv } from '../__fixtures__/mockEnv.ts'; +import { createPackedDir, setupTempDir } from '../__fixtures__/tempDir.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; +import type { ReleaseState } from '../utils/ReleaseState.ts'; + +// +// This test mocks all external interactions of runRelease but uses actual zip files. +// + +jest.unstable_mockModule('@azure/storage-blob', () => ({ + BlobServiceClient: class { + public accountName = 'stagingaccount'; + }, +})); + +const mockReleaseStateCreate = jest.fn(); +jest.unstable_mockModule('../utils/ReleaseState.ts', () => ({ + ReleaseState: { create: mockReleaseStateCreate }, +})); + +const releaseService = { createRelease: jest.fn() } as unknown as jest.Mocked; +jest.unstable_mockModule('../ESRPReleaseService.ts', () => ({ + ESRPReleaseService: { create: () => releaseService }, +})); + +jest.unstable_mockModule('../auth/getAadToken.ts', () => ({ + getAadToken: () => Promise.resolve({ token: 'token', expiresOnTimestamp: 0 }), +})); + +const { runRelease } = await import('../runRelease.ts'); + +describe('runRelease', () => { + const { getTempDir } = setupTempDir(); + let logger: MockLogger; + let state: jest.Mocked; + + function makeReleaseState(opts: { alreadyPublished?: string[] } = {}): typeof state { + const published = new Set(opts.alreadyPublished ?? []); + return { + get publishedCount() { + return published.size; + }, + hasPublished: jest.fn((layer: string) => published.has(layer)), + markPublished: jest.fn(async (layer: string) => { + await Promise.resolve(); + published.add(layer); + }), + } satisfies Partial as unknown as typeof state; + } + + function envWithTempPaths(layers: Record) { + const temp = getTempDir(); + const agentTemp = path.join(temp, 'agent'); + fs.mkdirSync(agentTemp, { recursive: true }); + + const packedDir = createPackedDir(temp, layers); + + const env = createMockEnv(); + env.packedPackagesPath = packedDir; + env.ado.agentTempDirectory = agentTemp; + env.ado.buildSourceVersion = 'commit-1'; + return env; + } + + beforeEach(() => { + jest.resetAllMocks(); + logger = new MockLogger(); + state = makeReleaseState(); + mockReleaseStateCreate.mockImplementation(() => Promise.resolve(state)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('zips and releases each unpublished layer in order, then marks them published', async () => { + const env = envWithTempPaths({ + '1': ['pkg-a-1.0.0.tgz'], + '2': ['pkg-b-2.0.0.tgz', 'pkg-c-3.0.0.tgz'], + }); + + await runRelease({ env, logger }); + + // Releases happen in sorted layer order + expect(releaseService.createRelease).toHaveBeenCalledTimes(2); + const layerVersions = releaseService.createRelease.mock.calls.map( + ([params]) => params.releaseRequestParams.productInfo.version + ); + expect(layerVersions).toEqual(['commit-1-1', 'commit-1-2']); + + // Each layer is then marked published + expect(state.markPublished).toHaveBeenCalledTimes(2); + expect(state.markPublished.mock.calls.map(c => c[0])).toEqual(['1', '2']); + + // Zip files were actually created on disk + const zipsDir = path.join(env.ado.agentTempDirectory, 'npm-zips'); + expect(fs.readdirSync(zipsDir).length).toBe(2); + }); + + it('processes 10+ zero-padded layer names in correct numeric order via lexical sort', async () => { + // beachball pads layer numbers to a uniform width (packPackage.ts), so layer names + // like '01', '02', ..., '10' lexically sort the same as numerically. This test pins + // that behavior so a regression to unpadded names (which would sort as ['1', '10', '2']) + // would be caught here. + const layers: Record = {}; + for (let i = 1; i <= 12; i++) { + const name = String(i).padStart(2, '0'); + layers[name] = [`pkg-${name}-1.0.0.tgz`]; + } + + await runRelease({ env: envWithTempPaths(layers), logger }); + + const publishCalls = state.markPublished.mock.calls.map(c => c[0]); + expect(publishCalls).toEqual(['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']); + }); + + it('skips layers that have already been published', async () => { + state = makeReleaseState({ alreadyPublished: ['1'] }); + + const env = envWithTempPaths({ + '1': ['pkg-a-1.0.0.tgz'], + '2': ['pkg-b-2.0.0.tgz'], + }); + + await runRelease({ env, logger }); + + expect(releaseService.createRelease).toHaveBeenCalledTimes(1); + expect(releaseService.createRelease.mock.calls[0][0]).toMatchObject({ + releaseRequestParams: { productInfo: { version: 'commit-1-2' } }, + }); + expect(state.markPublished).toHaveBeenCalledTimes(1); + expect(state.markPublished).toHaveBeenCalledWith('2'); + // Logged the skip + expect(logger.lines.some(l => l.includes('layer 1 (already published)'))).toBe(true); + }); + + it('strips an "org/" prefix from BUILD_REPOSITORY_NAME when computing the staging path prefix', async () => { + const env = envWithTempPaths({ '1': ['pkg-a.tgz'] }); + env.ado.buildRepositoryName = 'my-org/my-repo'; + + await runRelease({ env, logger }); + + expect(mockReleaseStateCreate).toHaveBeenCalledWith(expect.objectContaining({ repoName: 'my-repo' })); + expect(releaseService.createRelease).toHaveBeenCalledWith( + expect.objectContaining({ stagingBlobPathPrefix: 'my-repo' }) + ); + }); + + it('passes the bare repository name through unchanged when there is no "org/" prefix', async () => { + const env = envWithTempPaths({ '1': ['pkg-a.tgz'] }); + env.ado.buildRepositoryName = 'bare-repo'; + + await runRelease({ env, logger }); + + expect(mockReleaseStateCreate).toHaveBeenCalledWith(expect.objectContaining({ repoName: 'bare-repo' })); + }); + + it('propagates failures from createRelease', async () => { + const originalError = new Error('oh no'); + releaseService.createRelease.mockRejectedValue(originalError); + const env = envWithTempPaths({ '1': ['pkg-a.tgz'] }); + + const err = await runRelease({ env, logger }).catch(e => e as unknown); + expect(err).toBe(originalError); + expect(state.markPublished).not.toHaveBeenCalled(); + }); + + it('throws when no layer directories are found', async () => { + const env = envWithTempPaths({}); + + const err = await runRelease({ env, logger }).catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toContain('No layer directories found'); + expect(releaseService.createRelease).not.toHaveBeenCalled(); + }); + + it('only includes .tgz files in the layer zips (ignores other files)', async () => { + const env = envWithTempPaths({ '1': ['pkg-a-1.0.0.tgz', 'README.md'] }); + + await runRelease({ env, logger }); + + // Both files exist on disk, but only the .tgz is logged as added to the zip + const addedLines = logger.lines.filter(l => l.includes('- pkg') || l.includes('- README')); + expect(addedLines.length).toBe(1); + expect(addedLines[0]).toContain('- pkg-a-1.0.0.tgz'); + }); + + it('skips non-numeric subdirectories like "_manifest" (SBOM output) under packed packages', async () => { + const env = envWithTempPaths({ + '1': ['pkg-a-1.0.0.tgz'], + '2': ['pkg-b-2.0.0.tgz'], + _manifest: ['manifest.spdx.json'], // SBOM-style sibling directory + }); + + await runRelease({ env, logger }); + + // Only the two numbered layers should be released; "_manifest" should be silently skipped. + expect(releaseService.createRelease).toHaveBeenCalledTimes(2); + const layerVersions = releaseService.createRelease.mock.calls.map( + ([params]) => params.releaseRequestParams.productInfo.version + ); + expect(layerVersions).toEqual(['commit-1-1', 'commit-1-2']); + expect(state.markPublished.mock.calls.map(c => c[0])).toEqual(['1', '2']); + // No log line should mention _manifest (it's filtered out before logging) + expect(logger.lines.some(l => l.includes('_manifest'))).toBe(false); + }); + + it('throws when a layer directory contains no .tgz files', async () => { + const env = envWithTempPaths({ + '1': ['pkg-a-1.0.0.tgz'], + '2': ['README.md'], // no .tgz + }); + + const err = await runRelease({ env, logger }).catch(e => e as unknown); + expect(err).toBeInstanceOf(ReleaseError); + expect((err as ReleaseError).message).toMatch(/No \.tgz files found in layer directory.*[/\\]2$/); + // Layer 1 was released before the failure was hit + expect(releaseService.createRelease).toHaveBeenCalledTimes(1); + expect(state.markPublished).toHaveBeenCalledWith('1'); + expect(state.markPublished).not.toHaveBeenCalledWith('2'); + }); +}); diff --git a/packages/esrp-npm-release/src/__tests__/signing.test.ts b/packages/esrp-npm-release/src/__tests__/signing.test.ts new file mode 100644 index 000000000..42ed53417 --- /dev/null +++ b/packages/esrp-npm-release/src/__tests__/signing.test.ts @@ -0,0 +1,78 @@ +import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { MockLogger } from '../__fixtures__/MockLogger.ts'; +import { generateTestCert, isOpensslAvailable, type TestCert } from '../__fixtures__/testCert.ts'; +import { getKeyAndCertificatesFromPFX, getThumbprint, pemToDer } from '../auth/signing.ts'; + +// eslint-disable-next-line no-restricted-properties -- intentional skip when openssl is unavailable +const describeIfOpenssl = isOpensslAvailable() ? describe : describe.skip; + +describeIfOpenssl('signing utilities (openssl-based)', () => { + let testCert: TestCert; + let logger: MockLogger; + + beforeAll(() => { + testCert = generateTestCert(); + }); + + beforeEach(() => { + logger = new MockLogger(); + }); + + describe('getKeyAndCertificatesFromPFX', () => { + it('extracts the private key and the full certificate chain from a valid PFX', () => { + const { key, certificates } = getKeyAndCertificatesFromPFX(testCert.pfxBase64, logger); + + expect(key).toMatch(/^-----BEGIN PRIVATE KEY-----[\s\S]+-----END PRIVATE KEY-----$/); + expect(certificates).toEqual([ + expect.stringMatching(/^-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----$/), + expect.stringMatching(/^-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----$/), + ]); + }); + + it('returns certificates with the leaf (key-matching) cert at index 0, regardless of openssl output order', () => { + const { certificates } = getKeyAndCertificatesFromPFX(testCert.pfxBase64, logger); + expect(certificates).toHaveLength(2); + expect(pemToDer(certificates[0]).toString('hex')).toBe(pemToDer(testCert.leafCertPem).toString('hex')); + expect(pemToDer(certificates[1]).toString('hex')).toBe(pemToDer(testCert.caCertPem).toString('hex')); + }); + + it('logs which position the leaf was found at', () => { + getKeyAndCertificatesFromPFX(testCert.pfxBase64, logger); + + // Exactly one log line should report the leaf position + const positionLines = logger.lines.filter(l => l.includes('leaf is at')); + expect(positionLines).toHaveLength(1); + // Whichever position openssl emits the leaf at on this platform, it should be either + // "index 0 (using as-is)" or "last index (reversing)" + expect(positionLines[0]).toMatch(/leaf is at (index 0|last index)/); + expect(positionLines[0]).toContain('Found 2 certificate(s) in PFX'); + }); + + it('throws an informative error when the input is not valid base64 PFX content', () => { + expect(() => getKeyAndCertificatesFromPFX('not-a-real-pfx', logger)).toThrow( + 'Error processing PFX with `openssl' + ); + }); + }); + + describe('pemToDer', () => { + it('round-trips through openssl: re-encoding the DER as PEM matches the original', () => { + const der = pemToDer(testCert.leafCertPem); + // Compare DER lengths and hex content + expect(der.length).toBeGreaterThan(100); // a 2048-bit RSA cert is well over 100 bytes + expect(der.toString('base64')).toBe( + testCert.leafCertPem.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/g, '') + ); + }); + }); + + describe('getThumbprint', () => { + it('produces a SHA1 thumbprint matching openssl x509 -fingerprint -sha1', () => { + expect(getThumbprint(testCert.leafCertPem, 'sha1').toString('hex')).toBe(testCert.sha1ThumbprintHex); + }); + + it('produces a SHA256 thumbprint matching openssl x509 -fingerprint -sha256', () => { + expect(getThumbprint(testCert.leafCertPem, 'sha256').toString('hex')).toBe(testCert.sha256ThumbprintHex); + }); + }); +}); diff --git a/packages/esrp-npm-release/src/auth/generateJwsToken.ts b/packages/esrp-npm-release/src/auth/generateJwsToken.ts new file mode 100644 index 000000000..a534a69c6 --- /dev/null +++ b/packages/esrp-npm-release/src/auth/generateJwsToken.ts @@ -0,0 +1,37 @@ +import jws from 'jws'; +import type { ReleaseRequestMessage } from '../types/api.ts'; +import { getThumbprint, pemToDer } from './signing.ts'; + +export interface JwsTokenParams { + /** Certificate chain file content in PEM format */ + certificates: string[]; + /** Private key file content */ + privateKey: string; +} + +export function generateJwsToken(params: JwsTokenParams & { releaseRequest: ReleaseRequestMessage }): string { + const { releaseRequest, certificates, privateKey } = params; + + const expTicks = (BigInt(Date.now()) + 6n * 60n * 1000n) * 10000n + 621355968000000000n; + + // Create header with properly typed properties, then override x5c with the non-standard string format + const header: jws.Header = { + alg: 'RS256', + crit: ['exp', 'x5t'], + // Release service uses .NET ticks, not milliseconds (https://stackoverflow.com/a/7968483) + exp: expTicks, + // Release service uses hex format for thumbprint + x5t: getThumbprint(certificates[0], 'sha1').toString('hex'), + }; + + // Release service expects x5c as a '.' separated string, not the standard array format + (header as Record)['x5c'] = certificates + .map((c: string) => pemToDer(c).toString('base64url')) + .join('.'); + + return jws.sign({ + header, + payload: releaseRequest, + privateKey, + }); +} diff --git a/packages/esrp-npm-release/src/auth/getAadToken.ts b/packages/esrp-npm-release/src/auth/getAadToken.ts new file mode 100644 index 000000000..98eb11f99 --- /dev/null +++ b/packages/esrp-npm-release/src/auth/getAadToken.ts @@ -0,0 +1,64 @@ +import type { AccessToken } from '@azure/core-auth'; +import { ConfidentialClientApplication, type AuthenticationResult, type NodeAuthOptions } from '@azure/msal-node'; +import type { Logger } from '../utils/Logger.ts'; +import type { ReleaseHttpParams } from '../esrpApi/releaseHttp.ts'; +import { getKeyAndCertificatesFromPFX, getThumbprint } from './signing.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; + +export interface GetAadTokenParams extends Pick { + tenantId: string; + /** Typically this should be `['.default']` */ + scopes: string[]; + auth: { certPfxContent: string } | { idToken: string }; + logger: Logger; +} + +export type { AccessToken }; + +/** + * Get a `ConfidentialClientApplication` access token from AAD using a certificate. + * Throws a `ReleaseError` on failure. + */ +export async function getAadToken(params: GetAadTokenParams): Promise { + const { clientId, tenantId, auth, scopes, logger } = params; + + const authOptions: NodeAuthOptions = { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + }; + + if ('idToken' in auth) { + authOptions.clientAssertion = auth.idToken; + } else { + try { + const { key, certificates } = getKeyAndCertificatesFromPFX(auth.certPfxContent, logger); + const thumbprintSha256 = getThumbprint(certificates[0], 'sha256').toString('hex'); + authOptions.clientCertificate = { + thumbprintSha256, + privateKey: key, + x5c: certificates[0], + }; + } catch (err) { + throw new ReleaseError(`Error parsing cert info to acquire token`, { cause: err }); + } + } + + let result: AuthenticationResult | null; + const errorMessageBase = `Failed to acquire token for client "${clientId}" in tenant "${tenantId}" with scope ${JSON.stringify(scopes)}`; + try { + const cca = new ConfidentialClientApplication({ auth: authOptions }); + result = await cca.acquireTokenByClientCredential({ scopes }); + } catch (ex) { + throw new ReleaseError(errorMessageBase, { cause: ex }); + } + + if (!result || !result.expiresOn) { + throw new ReleaseError(`${errorMessageBase}: no result returned`); + } + + return { + token: result.accessToken, + expiresOnTimestamp: result.expiresOn.getTime(), + refreshAfterTimestamp: result.refreshOn?.getTime(), + }; +} diff --git a/packages/esrp-npm-release/src/auth/signing.ts b/packages/esrp-npm-release/src/auth/signing.ts new file mode 100644 index 000000000..b891c807f --- /dev/null +++ b/packages/esrp-npm-release/src/auth/signing.ts @@ -0,0 +1,74 @@ +import crypto from 'crypto'; +import execa from 'execa'; +import type { Logger } from '../utils/Logger.ts'; + +/** + * Convert a certificate from PEM format (base64 text with header/footer) into the raw + * DER binary format. + */ +export function pemToDer(input: string): Buffer { + return Buffer.from(input.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/g, ''), 'base64'); +} + +/** + * Get the thumbprint of a certificate with the specified algorithm. + */ +export function getThumbprint(certPem: string, algorithm: 'sha1' | 'sha256'): Buffer { + const certDer = pemToDer(certPem); + return crypto.createHash(algorithm).update(certDer).digest(); +} + +/** + * Extract the private key and all certificates from a PFX file using `openssl`. + * + * Returns `certificates` with the end-entity (leaf) certificate at index 0, identified by + * matching its public key against the extracted private key. The leaf is expected to be + * either the first or last cert that `openssl pkcs12` emits — which covers every realistic + * PFX producer (openssl, Windows certutil/`Export-PfxCertificate`, browsers, keytool, etc.). + * If neither the first nor last cert matches the key, this throws rather than guess. + * + * Throws an informative plain `Error` on any failure. + */ +export function getKeyAndCertificatesFromPFX( + pfxContent: string, + logger: Logger +): { key: string; certificates: string[] } { + const pfxCertificate = Buffer.from(pfxContent, 'base64'); + let result: execa.ExecaSyncReturnValue; + try { + result = execa.sync('openssl', ['pkcs12', '-nodes', '-passin', 'pass:'], { input: pfxCertificate }); + } catch (_err) { + const err = _err as execa.ExecaSyncError; + throw new Error(`Error processing PFX with \`${err.command}\`:\n${err.message}`); + } + + const key = result.stdout.match(/-----BEGIN PRIVATE KEY-----[\s\S]+?-----END PRIVATE KEY-----/)?.[0]; + if (!key) { + throw new Error('Private key not found in processed PFX'); + } + + const certMatches = result.stdout.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g); + if (!certMatches) { + throw new Error('Certificates not found in processed PFX'); + } + + // Identify the leaf cert by matching its public key against the private key. We only + // check the first and last positions since real-world PFX producers all put the leaf at + // one end or the other. + const keyPub = crypto.createPublicKey(key).export({ type: 'spki', format: 'der' }); + const matchesKey = (cert: string) => + crypto.createPublicKey(cert).export({ type: 'spki', format: 'der' }).equals(keyPub); + + let certificates: string[]; + if (matchesKey(certMatches[0])) { + logger.log(`Found ${certMatches.length} certificate(s) in PFX; leaf is at index 0 (using as-is)`); + certificates = certMatches; + } else if (matchesKey(certMatches[certMatches.length - 1])) { + logger.log(`Found ${certMatches.length} certificate(s) in PFX; leaf is at last index (reversing)`); + certificates = [...certMatches].reverse(); + } else { + throw new Error('Leaf certificate (matching the private key) is neither first nor last in the PFX'); + } + + return { key, certificates }; +} diff --git a/packages/esrp-npm-release/src/esrpApi/npmRelease.ts b/packages/esrp-npm-release/src/esrpApi/npmRelease.ts new file mode 100644 index 000000000..6c8356286 --- /dev/null +++ b/packages/esrp-npm-release/src/esrpApi/npmRelease.ts @@ -0,0 +1,136 @@ +import fs from 'fs'; +import path from 'path'; +import { generateJwsToken } from '../auth/generateJwsToken.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; +import { hashFileStream } from '../utils/hashFileStream.ts'; +import { FileHashType, type ProductInfo, type ReleaseRequestMessage } from '../types/api.ts'; + +export type GeneratedReleaseRequestMessage = ReleaseRequestMessage & + Required< + Pick< + ReleaseRequestMessage, + | 'driEmail' + | 'createdBy' + | 'owners' + | 'approvers' + | 'accessPermissionsInfo' + | 'productInfo' + | 'releaseInfo' + | 'files' + | 'jwsToken' + > + >; + +export interface CreateNpmReleaseRequestMessageParams { + correlationId: string; + /** email of the DRI for the team creating this release */ + driEmail: string[]; + /** created by email */ + createdBy: string; + /** owner emails */ + owners: string[]; + /** approver emails (all non-mandatory and auto-approved) */ + approvers: string[]; + /** your release title */ + releaseTitle: string; + productInfo: Required; + npmTag?: string; + file: { + /** Local file path */ + path: string; + /** SAS URL for the staged blob */ + sasBlobUrl: string; + }; + requestSigningCertificates: string[]; + requestSigningKey: string; +} + +/** + * Create a release request. Handles hashing the file, constructing the message, and JWS signing. + * Throws `ReleaseError` on any failure. + */ +export async function createNpmReleaseRequest( + params: CreateNpmReleaseRequestMessageParams +): Promise { + const { file } = params; + + let size: number; + let hash: Buffer; + try { + size = fs.statSync(file.path).size; + // Hash the file with a stream--most package tarballs are small, but some are not + hash = await hashFileStream('sha256', file.path); + } catch (err) { + throw new ReleaseError(`Failed to stat or hash file ${file.path}`, { cause: err }); + } + + const message: Omit = { + esrpCorrelationId: params.correlationId, + customerCorrelationId: params.correlationId, + driEmail: params.driEmail, + createdBy: { userPrincipalName: params.createdBy }, + owners: params.owners.map(email => ({ owner: { userPrincipalName: email } })), + approvers: params.approvers.map(email => ({ + approver: { userPrincipalName: email }, + isAutoApproved: true, + isMandatory: false, + })), + accessPermissionsInfo: { + mainPublisher: 'ESRPRELPACMAN', + }, + productInfo: params.productInfo, + releaseInfo: { + title: params.releaseTitle, + minimumNumberOfApprovers: 1, + isRevision: false, + properties: { + ReleaseContentType: 'npm', + IsRsm: 'false', + }, + }, + routingInfo: { + intent: 'packagedistribution', + contentType: 'npm', + // Don't default to "latest" here in case the package specifies the tag in publishConfig + ...(params.npmTag && { productState: params.npmTag }), + }, + files: [ + { + name: path.basename(file.path), + tenantFileLocation: file.sasBlobUrl, + tenantFileLocationType: 'AzureBlob', + sourceLocation: { type: 'azureBlob', blobUrl: file.sasBlobUrl }, + hashType: FileHashType.sha256, + hash: Array.from(hash), + sizeInBytes: size, + }, + ], + }; + + try { + const jwsToken = generateJwsToken({ + releaseRequest: message, + certificates: params.requestSigningCertificates, + privateKey: params.requestSigningKey, + }); + return { ...message, jwsToken }; + } catch (err) { + throw new ReleaseError(`Failed to generate JWS token for release request`, { cause: err }); + } +} + +export function redactReleaseRequest>( + message: TMessage +): TMessage { + message = structuredClone(message); + if (message.jwsToken) message.jwsToken = '***'; + message.files = message.files?.map(f => ({ + ...f, + tenantFileLocation: f.tenantFileLocation.replace(/\?.*$/, '?***'), + sourceLocation: { + ...f.sourceLocation, + blobUrl: f.sourceLocation?.blobUrl?.replace(/\?.*$/, '?***'), + }, + })); + return message; +} diff --git a/packages/esrp-npm-release/src/esrpApi/releaseHttp.ts b/packages/esrp-npm-release/src/esrpApi/releaseHttp.ts new file mode 100644 index 000000000..80f46f994 --- /dev/null +++ b/packages/esrp-npm-release/src/esrpApi/releaseHttp.ts @@ -0,0 +1,156 @@ +import type { + ReleaseRequestMessage, + ReleaseSubmitResponse, + ReleaseResultMessage, + ReleaseDetailsMessage, +} from '../types/api.ts'; +import { ReleaseError } from '../utils/ReleaseError.ts'; + +export interface ReleaseHttpParams { + /** ESRP onboarded AAD app client ID */ + clientId: string; + /** Bearer token for authentication as the AAD app */ + bearerToken: string; + /** Release correlation ID */ + releaseId: string; +} + +export const esrpApiEndpoint = 'https://api.esrp.microsoft.com/'; + +const esrpBaseUrl = `${esrpApiEndpoint}api/v3/releaseservices/clients/`; +// const esrpBaseUrl = 'https://ppe.api.esrp.microsoft.com/api/v3/releaseservices/clients/'; + +/** + * Submit a release request. + * Throws a `ReleaseError` if the request fails or the response can't be parsed. + */ +export async function submitRelease( + params: Omit & { releaseRequest: ReleaseRequestMessage } +): Promise>> { + const { clientId, bearerToken, releaseRequest } = params; + + let response: ReleaseSubmitResponse; + try { + response = await doHttpRequest({ + apiUrl: `${esrpBaseUrl}${clientId}/workflows/release/operations`, + bearerToken, + method: 'POST', + body: releaseRequest, + }); + } catch (err) { + throw new ReleaseError(`Failed to submit release`, { cause: err }); + } + + if (!response.operationId) { + // probably impossible? + throw new ReleaseError('Missing operationId on submitReleaseResult'); + } + + return response as ReleaseSubmitResponse & Required>; +} + +/** + * Get the status of a release request. + * Throws a `ReleaseError` if the request fails or the response can't be parsed. + */ +export async function getReleaseStatus(params: ReleaseHttpParams): Promise { + const { clientId, bearerToken, releaseId } = params; + + try { + return await doHttpRequest({ + apiUrl: `${esrpBaseUrl}${clientId}/workflows/release/operations/grs/${releaseId}`, + bearerToken, + method: 'GET', + }); + } catch (err) { + throw new ReleaseError(`Failed to get release status`, { cause: err }); + } +} + +/** + * Get the details of a release request. + * Throws an `Error` (not `ReleaseError`) if the request fails or the response can't be parsed. + */ +export function getReleaseDetails(params: ReleaseHttpParams): Promise { + const { clientId, bearerToken, releaseId } = params; + + return doHttpRequest({ + apiUrl: `${esrpBaseUrl}${clientId}/workflows/release/operations/grd/${releaseId}`, + bearerToken, + method: 'GET', + }); +} + +async function doHttpRequest( + params: Pick & { + apiUrl: string; + method: 'GET' | 'POST'; + body?: object; + } +): Promise { + const { apiUrl, bearerToken, method } = params; + + const body = params.body && JSON.stringify(params.body); + + const maxRetries = 10; + let lastError = ''; + let responseText = ''; + + for (let run = 1; run <= maxRetries; run++) { + let response: Response | undefined; + try { + // start the request - resolves when headers are received, and rejects on initial network errors + response = await fetch(apiUrl, { + method, + headers: { + Authorization: `Bearer ${bearerToken}`, + ...(body && { 'Content-Type': 'application/json' }), + }, + ...(body && { body }), + signal: AbortSignal.timeout(60_000), + }); + // wait for the whole response body + responseText = await response.text(); + if (response.ok) { + break; + } + } catch (err) { + const message = (err as Error).message || String(err); + // retry on transient errors, throw otherwise + if ( + !/fetch failed|terminated|aborted|timeout|TimeoutError|Timeout Error|RestError|Client network socket disconnected|socket hang up|ECONNRESET/i.test( + message + ) + ) { + // Intentionally not a ReleaseError so caller can add more context + throw new Error(`Request to ${apiUrl} failed: ${message}`); + } + lastError = message; + } + + if (response) { + const status = response.status; + // ignore transient errors: 408 Request Timeout, 429 Too Many Requests, and any 5xx + if (!(status === 408 || status === 429 || (status >= 500 && status < 600))) { + // Intentionally not a ReleaseError so caller can add more context + throw new Error(`Request to ${apiUrl} failed with status ${status}:\n${responseText}`); + } else { + lastError = `status ${status}: ${responseText}`; + } + } + + if (run === maxRetries) { + throw new Error(`Request to ${apiUrl} failed after ${maxRetries} attempts. Last error:\n${lastError}`); + } + + // schedule a retry (maximum delay is ~3 seconds) + const millis = Math.floor(Math.random() * 200 + 50 * Math.pow(1.5, run)); + await new Promise(c => setTimeout(c, millis)); + } + + try { + return JSON.parse(responseText) as TResult; + } catch { + throw new Error(`Request to ${apiUrl} succeeded but did not return valid JSON. Received:\n${responseText}`); + } +} diff --git a/packages/esrp-npm-release/src/index.ts b/packages/esrp-npm-release/src/index.ts new file mode 100644 index 000000000..1866cf3a0 --- /dev/null +++ b/packages/esrp-npm-release/src/index.ts @@ -0,0 +1,31 @@ +// Entry point for the ESRP npm release tool. +// Reads packed packages (produced by `beachball publish --pack-to-path `), +// zips each layer, and publishes them to npmjs.com via the ESRP Release API in dependency order. +// +// Based on the non-worker part of https://github.com/microsoft/vscode/blob/main/build/azure-pipelines/common/publish.ts +// called by https://github.com/microsoft/vscode/blob/main/build/azure-pipelines/product-publish.yml#L106 +// +// This file owns the only direct touchpoints with `process.env` and `process.exit` in the +// package; the actual orchestration lives in `runRelease` so it can be unit-tested. + +import { runRelease } from './runRelease.ts'; +import { getEnvOptions } from './utils/getEnvOptions.ts'; +import { Logger } from './utils/Logger.ts'; +import { ReleaseError } from './utils/ReleaseError.ts'; + +const logger = new Logger(); + +await runRelease({ env: getEnvOptions(), logger }).catch(err => { + if (err instanceof ReleaseError && err.alreadyLogged) { + // Error details were already printed -- just exit + } else if (err instanceof ReleaseError) { + // Expected error, not yet logged -- print the message and cause message (no stack trace) + logger.error(err.getMessageWithCause()); + } else { + // Unexpected error -- print full details including stack + logger.error('Unexpected error while running release!'); + logger.error((err as Error)?.stack || err); + } + // eslint-disable-next-line no-restricted-properties + process.exit(1); +}); diff --git a/packages/esrp-npm-release/src/runRelease.ts b/packages/esrp-npm-release/src/runRelease.ts new file mode 100644 index 000000000..c83f9e211 --- /dev/null +++ b/packages/esrp-npm-release/src/runRelease.ts @@ -0,0 +1,164 @@ +import { BlobServiceClient } from '@azure/storage-blob'; +import fs from 'fs'; +import path from 'path'; +import yazl from 'yazl'; +import { ESRPReleaseService } from './ESRPReleaseService.ts'; +import { getAadToken } from './auth/getAadToken.ts'; +import type { EnvOptions } from './types/EnvOptions.ts'; +import type { Logger } from './utils/Logger.ts'; +import { ReleaseError } from './utils/ReleaseError.ts'; +import { ReleaseState } from './utils/ReleaseState.ts'; + +export interface RunReleaseOptions { + env: EnvOptions; + logger: Logger; +} + +/** + * Run the full release workflow: load state, ensure ESRP is reachable, zip and release each + * unpublished layer in order. + * + * This is the unit-testable seam: tests pass an `env` literal and a `MockLogger`, and + * mock the modules this function imports (Azure clients, ReleaseState, ESRPReleaseService, + * AAD token, fs, yazl) via `jest.unstable_mockModule`. + */ +export async function runRelease({ env, logger }: RunReleaseOptions): Promise { + let stagingBlobServiceClient: BlobServiceClient; + try { + const storageUrl = `https://${env.staging.storageAccountName}.blob.core.windows.net/`; + logger.log(`Initializing BlobServiceClient for staging storage account at ${storageUrl}`); + stagingBlobServiceClient = new BlobServiceClient(storageUrl, { + // In the vscode example, the pipeline acquires the staging token in a previous step and stores it in + // PUBLISH_AUTH_TOKENS env, but that appears to only be necessary since multiple steps need the token + getToken: scopes => { + logger.log(`Acquiring AAD token for staging storage account "${env.staging.storageAccountName}"`); + return getAadToken({ + scopes: Array.isArray(scopes) ? scopes : [scopes], + tenantId: env.staging.tenantId, + clientId: env.staging.clientId, + auth: { idToken: env.staging.idToken }, + logger, + }).catch((err: ReleaseError) => { + // unclear how this will propagate, so go ahead and log and re-throw a generic message + logger.error( + `Error acquiring token for staging storage account "${env.staging.storageAccountName}":\n${err.getMessageWithCause()}` + ); + throw new ReleaseError('Error acquiring token (see above)', { alreadyLogged: true }); + }); + }, + }); + } catch (err) { + throw new ReleaseError( + `Failed to initialize BlobServiceClient for staging storage account "${env.staging.storageAccountName}"`, + { cause: err } + ); + } + + // Strip any "org/" prefix so only the repo name is used in the staging blob paths. + const repoName = env.ado.buildRepositoryName.replace(/^.*?\//, ''); + + logger.log(`Loading release state for repo "${repoName}" at source version ${env.ado.buildSourceVersion}`); + const state = await ReleaseState.create({ + blobServiceClient: stagingBlobServiceClient, + repoName, + sourceVersion: env.ado.buildSourceVersion, + }); + logger.log(`Release state loaded: ${state.publishedCount} layer(s) already published`); + + // Construct the release service once. It re-acquires AAD/SAS tokens per release internally + // since releasing each layer can take a long time (potentially exceeding token lifetimes). + logger.log('Initializing ESRP release service'); + const releaseService = await ESRPReleaseService.create({ + logger, + clientId: env.esrp.clientId, + tenantId: env.esrp.tenantId, + authCertificatePfx: env.esrp.authCertificatePfx, + requestSigningCertificatePfx: env.esrp.requestSigningCertificatePfx, + stagingBlobServiceClient, + }); + + const zipsDir = path.join(env.ado.agentTempDirectory, 'npm-zips'); + logger.log(`Creating temp directory for zipped packages at ${zipsDir}`); + fs.mkdirSync(zipsDir, { recursive: true }); + + logger.log(`Reading packed packages from ${env.packedPackagesPath}`); + const layers = fs + .readdirSync(env.packedPackagesPath) + .sort() + // Skip non-numeric entries (such as SBOM "_manifest") and non-directories + .filter(name => /^\d+$/.test(name) && fs.statSync(path.join(env.packedPackagesPath, name)).isDirectory()); + + if (!layers.length) { + throw new ReleaseError(`No layer directories found under ${env.packedPackagesPath}`); + } + + logger.log(`Found ${layers.length} layer(s) to release`); + + for (const layerNum of layers) { + if (state.hasPublished(layerNum)) { + logger.log(`✅ layer ${layerNum} (already published)`); + continue; + } + + const layerPrefix = 'layer-' + layerNum; + logger.startGroup(layerPrefix, `Starting release for layer ${layerNum} of ${layers.length}`); + + const layerDir = path.join(env.packedPackagesPath, layerNum); + const tgzFiles = fs + .readdirSync(layerDir) + .filter(file => file.endsWith('.tgz')) + .map(file => path.join(layerDir, file)); + if (!tgzFiles.length) { + throw new ReleaseError(`No .tgz files found in layer directory ${layerDir}`); + } + + const zipPath = path.join(zipsDir, `${layerPrefix}-${Date.now()}.zip`); + logger.log(`Zipping layer contents to ${zipPath}`); + const zipfile = new yazl.ZipFile(); + await new Promise((resolve, reject) => { + zipfile.outputStream.on('error', reject); + zipfile.outputStream.pipe(fs.createWriteStream(zipPath)).on('close', resolve).on('error', reject); + + for (const file of tgzFiles) { + logger.log(`- ${path.basename(file)}`); + zipfile.addFile(file, path.basename(file)); + } + zipfile.end(); + }).catch(err => { + throw new ReleaseError(`Error creating zip file for layer ${layerNum}`, { cause: err }); + }); + + logger.log(`Submitting release for layer ${layerNum} via ESRP`); + // From testing, this succeeds even if the versions already exist in the registry, + // with no way to distinguish... + await releaseService.createRelease({ + filePath: zipPath, + stagingBlobPathPrefix: repoName, + releaseRequestParams: { + createdBy: env.esrp.createdBy, + driEmail: env.esrp.driEmail, + owners: env.esrp.owners, + approvers: env.esrp.approvers, + productInfo: { + name: env.esrp.productName, + // This is an arbitrary string, not used as the published version + version: `${env.ado.buildSourceVersion}-${layerNum}`, + description: `${env.esrp.productName} packages - ${layerNum}`, + }, + releaseTitle: env.esrp.productName, + npmTag: env.esrp.npmTag, + }, + }); + logger.log('📦 Packages were successfully published to npm'); + + // This is done AFTER piercing to prevent silently trying to re-publish the same versions + // if there's a failure in any later step (since ESRP won't error on re-publishes) + logger.log(`Marking layer as published in release state`); + await state.markPublished(layerNum); + + logger.endGroup(); + logger.log(`✅ layer ${layerNum}`); + } + + logger.log(`All ${state.publishedCount} artifacts published!`); +} diff --git a/packages/esrp-npm-release/src/types/EnvOptions.ts b/packages/esrp-npm-release/src/types/EnvOptions.ts new file mode 100644 index 000000000..099ad21cd --- /dev/null +++ b/packages/esrp-npm-release/src/types/EnvOptions.ts @@ -0,0 +1,65 @@ +export interface EnvOptions { + /** Path to the directory of packed .tgz files organized into numbered layer subdirectories */ + packedPackagesPath: string; + + esrp: EsrpEnvOptions; + /** Info for temporarily uploading packages to a storage account in your team's subscription */ + staging: StagingEnvOptions; + /** + * Predefined ADO pipeline variables (set automatically by the agent). + * https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml + */ + ado: AdoEnvOptions; +} + +export interface EsrpEnvOptions { + /** Release product name */ + productName: string; + /** + * Optional npm dist-tag for the published packages. When unspecified, ESRP will read + * `publishConfig` from each package. + */ + npmTag: string | undefined; + /** Email of the user creating the release */ + createdBy: string; + /** Email of the DRI for the team creating the release */ + driEmail: string[]; + /** Owner emails */ + owners: string[]; + /** Approver emails (all non-mandatory and auto-approved) */ + approvers: string[]; + + /** Production tenant ID used for your ESRP app registration */ + tenantId: string; + /** Client ID used for your ESRP app registration in a production tenant */ + clientId: string; + + /** Base64-encoded PFX certificate used for authenticating to ESRP AAD */ + authCertificatePfx: string; + /** Base64-encoded PFX certificate used for signing JWS tokens in release requests */ + requestSigningCertificatePfx: string; +} + +export interface StagingEnvOptions { + /** Storage account name for staging the packages */ + storageAccountName: string; + /** Client ID used for storage account access */ + clientId: string; + /** ID token used for storage account access */ + idToken: string; + /** Tenant ID used for storage account access */ + tenantId: string; +} + +/** + * ADO built-in variables used by this package. + * https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables + */ +export interface AdoEnvOptions { + /** ADO `Agent.TempDirectory` */ + agentTempDirectory: string; + /** Git commit of the source */ + buildSourceVersion: string; + /** Repository name (for GitHub-connected repos this is "org/repo"; bare name for ADO Repos) */ + buildRepositoryName: string; +} diff --git a/packages/esrp-npm-release/src/types/api.ts b/packages/esrp-npm-release/src/types/api.ts new file mode 100644 index 000000000..061a56c26 --- /dev/null +++ b/packages/esrp-npm-release/src/types/api.ts @@ -0,0 +1,252 @@ +// These types are derived from the ESRP OpenAPI specification + +// Enums (in a format that allows running TS in Node natively) + +export const StatusCode = Object.freeze({ + /** The workflow is in passed state and is successful. */ + Pass: 'pass', + /** System is still running the workflow. */ + Inprogress: 'inprogress', + /** There are problems with the workflow and user can resubmit the same file again for signing or scanning. */ + FailCanRetry: 'failCanRetry', + /** + * There are problems with the user provided input to the workflow. + * It's recommended not to resubmit same workflow without changing input values which may be wrong. + */ + FailDoNotRetry: 'failDoNotRetry', + /** File may have been flagged by malware engine or may have been under investigation by support. */ + PendingAnalysis: 'pendingAnalysis', + /** Release has been cancelled. -- TODO seeing some code checking "aborted"? */ + Cancelled: 'cancelled', +}); +export type StatusCode = (typeof StatusCode)[keyof typeof StatusCode]; + +export const FileHashType = Object.freeze({ + sha256: 0, + sha1: 1, +}); +export type FileHashType = (typeof FileHashType)[keyof typeof FileHashType]; + +export const FileLocationType = Object.freeze({ + AzureBlob: 'azureBlob', +}); +export type FileLocationType = (typeof FileLocationType)[keyof typeof FileLocationType]; + +// Interfaces + +export interface UserInfo { + /** user email */ + userPrincipalName?: string; +} + +export interface ApproverInfo { + approver?: UserInfo; + isAutoApproved?: boolean; + isMandatory?: boolean; +} + +export interface OwnerInfo { + owner?: UserInfo; +} + +export interface AccessPermissionsInfo { + /** @example 'ESRPRelTest' */ + mainPublisher?: string; + /** @deprecated */ + releasePublishers?: string[]; + /** @example { AllDownloadEntities: ['CBDSTEST'] } */ + channelDownloadEntityDetails?: Record; +} + +export interface FileLocation { + type: FileLocationType; + // these are not marked with NullValueHandling annotations, but aren't always provided in reality (not sure about blobUrl) + /** blob URL for type AzureBlob */ + blobUrl?: string; + /** URI */ + uncPath?: string; + /** URI */ + url?: string; +} + +export interface FileDownloadDetails { + portalName?: string; + downloadUrl?: string; +} + +export interface ReleaseFileInfo { + name: string; + /** sha256 hash of file (array of bytes or a string) */ + hash: number[] | string; + sourceLocation: FileLocation; + sizeInBytes?: number; + hashType?: FileHashType; + fileId?: unknown; + distributionRelativePath?: string; + partNumber?: string; + friendlyFileName?: string; + tenantFileLocationType: 'AzureBlob' | '1'; + tenantFileLocation: string; + signedEngineeringCopyLocation?: string; + encryptedDistributionBlobLocation?: string; + preEncryptedDistributionBlobLocation?: string; + secondaryDistributionHashRequired?: boolean; + secondaryDistributionHashType?: FileHashType; + lastModifiedAt?: string; + cultureCodes?: string[]; + displayFileInDownloadCenter?: boolean; + isPrimaryFileInDownloadCenter?: boolean; + fileDownloadDetails?: FileDownloadDetails[]; +} + +export interface ReleaseInfo { + title?: string; + minimumNumberOfApprovers?: number; + /** + * @example { ReleaseContentType: 'sw electronic' } + * @example { ReleaseContentType: 'InstallPackage' } + * @example { ReleaseContentType: 'npm', IsRsm: 'false' } + */ + properties?: Record; + isRevision?: boolean; + revisionNumber?: string; +} + +export interface ProductInfo { + /** Name of the product */ + name?: string; + /** Version of the product (for npm, this is arbitrary, not the package version) */ + version?: string; + /** Description of the product */ + description?: string; +} + +export interface RoutingInfo { + /** + * intent per onboarding + * - `'Product Release'` for compliance or download center + * - `'filedownloadlinkgeneration'` for static link release + * - `'packagedistribution'` for npm release + */ + intent?: string; + contentType?: string; + contentOrigin?: string; + /** for npm releases, this is the dist-tag */ + productState?: string; + audience?: string; +} + +export interface DownloadCenterLocaleInfo { + cultureCode?: string; + downloadTitle?: string; + shortName?: string; + shortDescription?: string; + longDescription?: string; + instructions?: string; + additionalInfo?: string; + keywords?: string[]; + version?: string; + relatedLinks?: Record; +} + +export interface DownloadCenterInfo { + downloadCenterId?: number; + publishToDownloadCenter?: boolean; + publishingGroup?: string; + operatingSystems?: string[]; + relatedReleases?: string[]; + /** @example ['KB123456'] */ + kbNumbers?: string[]; + sbNumbers?: string[]; + locales?: DownloadCenterLocaleInfo[]; + additionalProperties?: Record; +} + +export interface ReleaseRequestMessage { + /** email of the DRI for the team creating this release */ + driEmail?: string[]; + groupId?: string; + customerCorrelationId?: string; + esrpCorrelationId?: string; + contextData?: Record; + releaseInfo?: ReleaseInfo; + productInfo?: ProductInfo; + files?: ReleaseFileInfo[]; + routingInfo?: RoutingInfo; + createdBy?: UserInfo; + owners?: OwnerInfo[]; + approvers?: ApproverInfo[]; + accessPermissionsInfo?: AccessPermissionsInfo; + jwsToken?: string; + publisherId?: string; + downloadCenterInfo?: DownloadCenterInfo; +} + +export interface ReleaseSubmitResponse { + operationId?: string; + esrpCorrelationId?: string; + code?: string; + message?: string; + target?: string; + innerError?: unknown; +} + +export interface InnerServiceError { + code?: string; + details?: Record; + innerError?: InnerServiceError; +} + +export interface ReleaseError { + errorCode?: number; + errorMessages?: string[]; +} + +export interface ReleaseActivityInfo { + activityId?: string; + activityType?: string; + name?: string; + status?: string; + errorCode?: number; + errorMessages?: string[]; + beginTime?: string; + endTime?: string; + lastModifiedAt?: string; +} + +export interface ReleaseResultMessage { + activities?: ReleaseActivityInfo[]; + // TODO should this be childworkflowType or childWorkflowType? + childworkflowType?: string; + childWorkflowType?: string; + clientId?: string; + customerCorrelationId?: string; + // TODO should this be errorinfo or errorInfo? + errorinfo?: InnerServiceError; + errorInfo?: InnerServiceError; + groupId?: string; // + lastModifiedAt?: string; + operationId?: string; + releaseError?: ReleaseError; + requestSubmittedAt?: string; + routedRegion?: string; + status?: StatusCode; + totalFileCount?: number; + totalReleaseSize?: number; + version?: string; +} + +export interface ReleaseDetailsMessage extends ReleaseResultMessage { + clusterRegion?: string; + correlationVector?: string; + releaseCompletedAt?: string; + releaseInfo?: ReleaseInfo; + productInfo?: ProductInfo; + createdBy?: UserInfo; + owners?: OwnerInfo[]; + accessPermissionsInfo?: AccessPermissionsInfo; + files?: ReleaseFileInfo[]; + comments?: string[]; + cancellationReason?: string; + downloadCenterInfo?: DownloadCenterInfo; +} diff --git a/packages/esrp-npm-release/src/utils/Logger.ts b/packages/esrp-npm-release/src/utils/Logger.ts new file mode 100644 index 000000000..aeefebab9 --- /dev/null +++ b/packages/esrp-npm-release/src/utils/Logger.ts @@ -0,0 +1,41 @@ +export type LogMethod = 'log' | 'warn' | 'error'; + +export class Logger { + #prefix: string | undefined; + #console: Pick; + + public constructor(prefix?: string, consoleImpl?: Pick) { + this.#prefix = prefix; + this.#console = consoleImpl ?? console; + } + + /** Get the current prefix (if any) as an array that can be spread into console methods */ + private get prefix(): string[] { + return this.#prefix ? [`[${this.#prefix}]`] : []; + } + + public startGroup(prefix: string | undefined, title: string): void { + this.#console.log(`##[group]${title}`); + this.#prefix = prefix; + } + + public endGroup(): void { + this.#console.log('##[endgroup]'); + this.#prefix = undefined; + } + + /** Log a prefixed message */ + public log(...args: unknown[]): void { + this.#console.log(...this.prefix, ...args); + } + + /** Log a prefixed warning, which will also be shown as an ADO build warning */ + public warn(...args: unknown[]): void { + this.#console.warn(`##vso[task.logissue type=warning]`, ...this.prefix, ...args); + } + + /** Log a prefixed error, which will also be shown as an ADO build error */ + public error(...args: unknown[]): void { + this.#console.error(`##vso[task.logissue type=error]`, ...this.prefix, ...args); + } +} diff --git a/packages/esrp-npm-release/src/utils/ReleaseError.ts b/packages/esrp-npm-release/src/utils/ReleaseError.ts new file mode 100644 index 000000000..140235a38 --- /dev/null +++ b/packages/esrp-npm-release/src/utils/ReleaseError.ts @@ -0,0 +1,36 @@ +/** + * Custom error class for expected/handled release errors. + * + * When `alreadyLogged` is true, it means the detailed error information has already been printed + * to stderr before the error was thrown. The top-level exit handler should NOT re-log the error + * details in that case. + * + * If `cause` is provided and `alreadyLogged` isn't true, the exit handler will print the message + * from the cause. + */ +export class ReleaseError extends Error { + /** If true, detailed error info was already logged via console.error before throwing. */ + public alreadyLogged: boolean; + + public constructor( + message: string, + options?: { + /** If true, the exit handler won't log anything */ + alreadyLogged?: boolean; + /** + * Underlying cause. Its message will be printed by the exit handler if `alreadyLogged` + * is not set. + */ + cause?: unknown; + } + ) { + super(message, { cause: options?.cause }); + this.name = 'ReleaseError'; + this.alreadyLogged = !!options?.alreadyLogged; + } + + public getMessageWithCause(): string { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return `${this.message}${this.cause ? `:\n${(this.cause as Error).message || String(this.cause)}` : ''}`; + } +} diff --git a/packages/esrp-npm-release/src/utils/ReleaseState.ts b/packages/esrp-npm-release/src/utils/ReleaseState.ts new file mode 100644 index 000000000..0ffcbeddc --- /dev/null +++ b/packages/esrp-npm-release/src/utils/ReleaseState.ts @@ -0,0 +1,98 @@ +import type { ContainerClient, BlobServiceClient } from '@azure/storage-blob'; +import { ReleaseError } from './ReleaseError.ts'; + +const stateContainerName = 'release-state'; + +const getContainerDesc = (accountName: string) => + `container "${stateContainerName}" in storage account "${accountName}"` as const; + +/** + * Tracks which layers have been successfully published for the current source version. + * + * State is persisted to Azure Blob Storage in the staging storage account under the + * `release-state` container. Each successfully-published layer corresponds to one empty + * marker blob at `{repoName}/{buildSourceVersion}/{layerName}`. This allows the tool to + * resume from where it left off when ADO retries a failed stage or task, skipping layers + * that were already published to npm. The repo name prefix isolates state between + * different repositories sharing the same staging storage account. + * + * Keying by `buildSourceVersion` (rather than build ID + stage attempt) means state is + * shared across all retries and reruns for the same git commit, which is what we want + * since the same source version always produces the same layers. + * + * The recommended bicep template includes a lifecycle management policy to clean up blobs + * after a given window (90 days as of writing). + */ +export class ReleaseState { + #publishedLayers: Set; + #containerClient: ContainerClient; + #prefix: string; + + /** + * Initialize the ReleaseState from persisted state in Azure Blob Storage, loading the list of + * already-published layers for this `repoName` + `sourceVersion`. Throws `ReleaseError` on any issue. + */ + public static async create(params: { + blobServiceClient: BlobServiceClient; + repoName: string; + sourceVersion: string; + }): Promise { + const { blobServiceClient, repoName, sourceVersion } = params; + const desc = getContainerDesc(blobServiceClient.accountName); + + let containerClient: ContainerClient; + try { + containerClient = blobServiceClient.getContainerClient(stateContainerName); + } catch (err) { + throw new ReleaseError(`Error initializing client for ${desc}`, { cause: err }); + } + + await containerClient.createIfNotExists().catch(err => { + throw new ReleaseError(`Error creating or accessing ${desc}`, { cause: err }); + }); + + const prefix = `${repoName}/${sourceVersion}/`; + const publishedLayers = new Set(); + try { + for await (const blob of containerClient.listBlobsFlat({ prefix })) { + publishedLayers.add(blob.name.slice(prefix.length)); + } + } catch (err) { + throw new ReleaseError(`Error listing blobs with prefix "${prefix}" in ${desc}`, { cause: err }); + } + + return new ReleaseState(containerClient, prefix, publishedLayers); + } + + private constructor(containerClient: ContainerClient, prefix: string, publishedLayers: Set) { + this.#containerClient = containerClient; + this.#prefix = prefix; + this.#publishedLayers = publishedLayers; + } + + /** Number of layers published */ + public get publishedCount(): number { + return this.#publishedLayers.size; + } + + /** Returns whether the layer has already been published */ + public hasPublished(layerNum: string): boolean { + return this.#publishedLayers.has(layerNum); + } + + /** Marks the layer as published (throws `ReleaseError` on any issue) */ + public async markPublished(layerNum: string): Promise { + const blobName = this.#prefix + layerNum; + try { + const blobClient = this.#containerClient.getBlockBlobClient(blobName); + await blobClient.upload('', 0); + this.#publishedLayers.add(layerNum); + } catch (err) { + throw new ReleaseError( + `Error marking layer ${layerNum} as published in persisted release state ` + + `(${getContainerDesc(this.#containerClient.accountName)})`, + { cause: err } + ); + } + } +} diff --git a/packages/esrp-npm-release/src/utils/getEnvOptions.ts b/packages/esrp-npm-release/src/utils/getEnvOptions.ts new file mode 100644 index 000000000..649384109 --- /dev/null +++ b/packages/esrp-npm-release/src/utils/getEnvOptions.ts @@ -0,0 +1,68 @@ +import type { EnvOptions } from '../types/EnvOptions.ts'; +import { ReleaseError } from './ReleaseError.ts'; + +/** + * Read environment variables and return a fully-populated `EnvOptions` object. + * Throws `ReleaseError` listing every missing required variable. + * + * Accepts an optional `env` source (defaulting to `process.env`) so this can be unit-tested + * without mutating real environment variables. + */ +export function getEnvOptions(env: NodeJS.ProcessEnv = process.env): EnvOptions { + const missingEnv: string[] = []; + + function getEnv(name: string, options?: { defaultValue?: string }): string; + function getEnv(name: string, options: { isOptional: true }): string | undefined; + function getEnv(name: string, options?: { defaultValue?: string; isOptional?: boolean }): string | undefined { + const result = env[name]; + if (result) return result; + if (options?.defaultValue !== undefined) return options.defaultValue; + if (options?.isOptional) return undefined; + // collect all errors and throw at the end + missingEnv.push(name); + return ''; + } + + // ESRP_USER serves as a fallback default for the contact email fields below + const defaultUser = getEnv('ESRP_USER', { isOptional: true }); + + const result: EnvOptions = { + packedPackagesPath: getEnv('PACKED_PACKAGES_PATH'), + esrp: { + productName: getEnv('ESRP_PRODUCT_NAME'), + // skip if unspecified so ESRP will read publishConfig + npmTag: getEnv('ESRP_NPM_TAG', { isOptional: true }), + createdBy: getEnv('ESRP_CREATED_BY', { defaultValue: defaultUser }), + driEmail: [getEnv('ESRP_DRI_EMAIL', { defaultValue: defaultUser })], + owners: splitString(getEnv('ESRP_OWNERS', { defaultValue: defaultUser })), + approvers: splitString(getEnv('ESRP_APPROVERS', { defaultValue: defaultUser })), + tenantId: getEnv('ESRP_TENANT_ID'), + clientId: getEnv('ESRP_CLIENT_ID'), + authCertificatePfx: getEnv('ESRP_AUTH_CERT'), + requestSigningCertificatePfx: getEnv('ESRP_REQUEST_SIGNING_CERT'), + }, + staging: { + storageAccountName: getEnv('STAGING_STORAGE_ACCOUNT_NAME'), + clientId: getEnv('STAGING_CLIENT_ID'), + idToken: getEnv('STAGING_ID_TOKEN'), + tenantId: getEnv('STAGING_TENANT_ID'), + }, + ado: { + agentTempDirectory: getEnv('AGENT_TEMPDIRECTORY'), + buildSourceVersion: getEnv('BUILD_SOURCEVERSION'), + buildRepositoryName: getEnv('BUILD_REPOSITORY_NAME'), + }, + }; + + if (missingEnv.length) { + throw new ReleaseError(`Missing required environment variables: ${missingEnv.join(', ')}`); + } + return result; +} + +function splitString(value: string): string[] { + return value + .split(',') + .map(s => s.trim()) + .filter(Boolean); +} diff --git a/packages/esrp-npm-release/src/utils/hashFileStream.ts b/packages/esrp-npm-release/src/utils/hashFileStream.ts new file mode 100644 index 000000000..9b0b9242d --- /dev/null +++ b/packages/esrp-npm-release/src/utils/hashFileStream.ts @@ -0,0 +1,17 @@ +import crypto from 'crypto'; +import fs from 'fs'; + +/** + * Hash a file using a stream (in case the file is large). + */ +export function hashFileStream(hashName: 'sha256', filePath: string): Promise { + const stream = fs.createReadStream(filePath); + return new Promise((resolve, reject) => { + const shasum = crypto.createHash(hashName); + + stream + .on('data', shasum.update.bind(shasum)) + .on('error', reject) + .on('end', () => resolve(shasum.digest())); + }); +} diff --git a/packages/esrp-npm-release/tsconfig.json b/packages/esrp-npm-release/tsconfig.json new file mode 100644 index 000000000..86363207e --- /dev/null +++ b/packages/esrp-npm-release/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@microsoft/beachball-scripts/config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "moduleResolution": "bundler", + "module": "esnext", + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} diff --git a/packages/esrp-npm-release/tsconfig.test.json b/packages/esrp-npm-release/tsconfig.test.json new file mode 100644 index 000000000..690a9e2e6 --- /dev/null +++ b/packages/esrp-npm-release/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "verbatimModuleSyntax": false, + "ignoreDeprecations": "6.0", + "esModuleInterop": true, + // ts-jest misuses this to disable type checking + "isolatedModules": true + } +} diff --git a/scripts/bundleNode.ts b/scripts/bundleNode.ts index a91044528..7543b3e97 100644 --- a/scripts/bundleNode.ts +++ b/scripts/bundleNode.ts @@ -17,6 +17,7 @@ await bundleNode({ verifyFiles: false, esbuildOptions: { splitting: false }, unacceptableLicenseTest, + excludeFromNotice: dep => dep.name.startsWith('@azure/') && dep.license === 'MIT', }).catch(err => { if (!(err instanceof BundleError && err.alreadyLogged)) { console.error(err.stack || String(err)); diff --git a/yarn.lock b/yarn.lock index 9be9210b5..e0fe2a5d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -76,6 +76,185 @@ __metadata: languageName: node linkType: hard +"@azure/abort-controller@npm:^2.0.0, @azure/abort-controller@npm:^2.1.2": + version: 2.1.2 + resolution: "@azure/abort-controller@npm:2.1.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/3771b6820e33ebb56e79c7c68e2288296b8c2529556fbd29cf4cf2fbff7776e7ce1120072972d8df9f1bf50e2c3224d71a7565362b589595563f710b8c3d7b79 + languageName: node + linkType: hard + +"@azure/core-auth@npm:^1.10.0, @azure/core-auth@npm:^1.10.1, @azure/core-auth@npm:^1.9.0": + version: 1.10.1 + resolution: "@azure/core-auth@npm:1.10.1" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-util": "npm:^1.13.0" + tslib: "npm:^2.6.2" + checksum: 10c0/83fd96e43cf8ca3e1cf6c7677915ca1433d6e331cb7352b64a3f93d9fd71dcddf77e8b46f2bb2a5db49ce87016ed30ebaca88034a0acf321e86ba17c0eb3329e + languageName: node + linkType: hard + +"@azure/core-client@npm:^1.9.3": + version: 1.10.1 + resolution: "@azure/core-client@npm:1.10.1" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-auth": "npm:^1.10.0" + "@azure/core-rest-pipeline": "npm:^1.22.0" + "@azure/core-tracing": "npm:^1.3.0" + "@azure/core-util": "npm:^1.13.0" + "@azure/logger": "npm:^1.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/f88b3df77e50c07eccc1a4bc1c12e626620be12027dd100682116664c4cc676ee1f78427e55ce8750a311762f75fdd41f99ce289c06b78a3b18e491d622d0579 + languageName: node + linkType: hard + +"@azure/core-http-compat@npm:^2.2.0": + version: 2.4.0 + resolution: "@azure/core-http-compat@npm:2.4.0" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + peerDependencies: + "@azure/core-client": ^1.10.0 + "@azure/core-rest-pipeline": ^1.22.0 + checksum: 10c0/160cdd9c4f8bae7ad60099586dcd3316d725d2969d90806ffb7705258d7fa459ee5fb98319def6b5c75881bc8063ce2da031497f457e8ebd36e512adce965967 + languageName: node + linkType: hard + +"@azure/core-lro@npm:^2.2.0": + version: 2.7.2 + resolution: "@azure/core-lro@npm:2.7.2" + dependencies: + "@azure/abort-controller": "npm:^2.0.0" + "@azure/core-util": "npm:^1.2.0" + "@azure/logger": "npm:^1.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/bee809e47661b40021bbbedf88de54019715fdfcc95ac552b1d901719c29d78e293eeab51257b8f5155aac768eb4ea420715004d00d6e32109f5f97db5960d39 + languageName: node + linkType: hard + +"@azure/core-paging@npm:^1.6.2": + version: 1.6.2 + resolution: "@azure/core-paging@npm:1.6.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/c727782f8dc66eff50c03421af2ca55f497f33e14ec845f5918d76661c57bc8e3a7ca9fa3d39181287bfbfa45f28cb3d18b67c31fd36bbe34146387dbd07b440 + languageName: node + linkType: hard + +"@azure/core-rest-pipeline@npm:^1.19.1, @azure/core-rest-pipeline@npm:^1.22.0": + version: 1.23.0 + resolution: "@azure/core-rest-pipeline@npm:1.23.0" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-auth": "npm:^1.10.0" + "@azure/core-tracing": "npm:^1.3.0" + "@azure/core-util": "npm:^1.13.0" + "@azure/logger": "npm:^1.3.0" + "@typespec/ts-http-runtime": "npm:^0.3.4" + tslib: "npm:^2.6.2" + checksum: 10c0/3161073e478d12c3920003d15246ac30c5210121da77aa65652d3fa89ceac3b799d281c19df631bf45b07cf29547b5ec411ecd03bbc357219ca620f5cd0a4b40 + languageName: node + linkType: hard + +"@azure/core-tracing@npm:^1.2.0, @azure/core-tracing@npm:^1.3.0": + version: 1.3.1 + resolution: "@azure/core-tracing@npm:1.3.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/0cb26db9ab5336a1867cc9cd0bd42b1702406d0f76420385789d1a96c8702a38cb081838ea73cd707bb7b340c4386499cf6e77538cacfda4467c251fe2ffa32b + languageName: node + linkType: hard + +"@azure/core-util@npm:^1.11.0, @azure/core-util@npm:^1.13.0, @azure/core-util@npm:^1.2.0": + version: 1.13.1 + resolution: "@azure/core-util@npm:1.13.1" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@typespec/ts-http-runtime": "npm:^0.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/37067621cdac933c51775c26648fdcea315f07b08bd875cff4610e403eabf9c12532525f0bf094e258dadc03a55d35f12c9242f662526847b32c85cdcc2d6603 + languageName: node + linkType: hard + +"@azure/core-xml@npm:^1.4.5": + version: 1.5.1 + resolution: "@azure/core-xml@npm:1.5.1" + dependencies: + fast-xml-parser: "npm:^5.5.9" + tslib: "npm:^2.8.1" + checksum: 10c0/8190762e96104e7ae58d6db98744e609aea5d06b8e4c44883dc99be5ae242b9fb258741f2a101221bb315eeb322b9b2e580d68725b5861aa6b44600283c841b7 + languageName: node + linkType: hard + +"@azure/logger@npm:^1.0.0, @azure/logger@npm:^1.1.4, @azure/logger@npm:^1.3.0": + version: 1.3.0 + resolution: "@azure/logger@npm:1.3.0" + dependencies: + "@typespec/ts-http-runtime": "npm:^0.3.0" + tslib: "npm:^2.6.2" + checksum: 10c0/aaa6a88fd4f26d41100865ff2c53b400347f632d315d9ae8ffa28db03974d35461e743031bdca40cad617ace172d1ba598ffdd18c345ebc564f63a51c32c4a29 + languageName: node + linkType: hard + +"@azure/msal-common@npm:16.5.2": + version: 16.5.2 + resolution: "@azure/msal-common@npm:16.5.2" + checksum: 10c0/e7c55eea2563adb6561f663b4a649ee62823102eeb5e4632b2ec1062071619900910015625b2465781c57d986384c05186afc82560cb7ea879526319e8626bb2 + languageName: node + linkType: hard + +"@azure/msal-node@npm:^5.1.5": + version: 5.1.5 + resolution: "@azure/msal-node@npm:5.1.5" + dependencies: + "@azure/msal-common": "npm:16.5.2" + jsonwebtoken: "npm:^9.0.0" + checksum: 10c0/476690a23931349281e9226ecb1a2124e45af7a8c1415315c9bf7f7e1f75dab8a7e33190cb58fa057f3128839733eec53e8bd9133a5123e774721f890efabe88 + languageName: node + linkType: hard + +"@azure/storage-blob@npm:^12.31.0": + version: 12.31.0 + resolution: "@azure/storage-blob@npm:12.31.0" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-auth": "npm:^1.9.0" + "@azure/core-client": "npm:^1.9.3" + "@azure/core-http-compat": "npm:^2.2.0" + "@azure/core-lro": "npm:^2.2.0" + "@azure/core-paging": "npm:^1.6.2" + "@azure/core-rest-pipeline": "npm:^1.19.1" + "@azure/core-tracing": "npm:^1.2.0" + "@azure/core-util": "npm:^1.11.0" + "@azure/core-xml": "npm:^1.4.5" + "@azure/logger": "npm:^1.1.4" + "@azure/storage-common": "npm:^12.3.0" + events: "npm:^3.0.0" + tslib: "npm:^2.8.1" + checksum: 10c0/ed228de94a7a8d96a575eb8fac8e9b042ce2d4a272ad96204766d0c20618e8c4adf03eb30c50c4227d569b6a69e136a6450b2849ae1e4918b8db1c3773a9041d + languageName: node + linkType: hard + +"@azure/storage-common@npm:^12.3.0": + version: 12.3.0 + resolution: "@azure/storage-common@npm:12.3.0" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-auth": "npm:^1.9.0" + "@azure/core-http-compat": "npm:^2.2.0" + "@azure/core-rest-pipeline": "npm:^1.19.1" + "@azure/core-tracing": "npm:^1.2.0" + "@azure/core-util": "npm:^1.11.0" + "@azure/logger": "npm:^1.1.4" + events: "npm:^3.3.0" + tslib: "npm:^2.8.1" + checksum: 10c0/e2b40ed6e93041a82518467467bf1e31201a08a35eeeae44b5b26bd1085fb2199cdf2992682fcb1a503884b0e7b71236f8434b451664430c4620d94492a2f105 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -2213,6 +2392,25 @@ __metadata: languageName: unknown linkType: soft +"@microsoft/esrp-npm-release@workspace:packages/esrp-npm-release": + version: 0.0.0-use.local + resolution: "@microsoft/esrp-npm-release@workspace:packages/esrp-npm-release" + dependencies: + "@azure/core-auth": "npm:^1.10.1" + "@azure/msal-node": "npm:^5.1.5" + "@azure/storage-blob": "npm:^12.31.0" + "@microsoft/beachball-scripts": "workspace:^" + "@types/jws": "npm:^3.2.11" + "@types/yazl": "npm:^3.3.1" + cross-env: "npm:^10.1.0" + execa: "npm:^5.1.1" + jws: "patch:jws@npm%3A4.0.1#~/.yarn/patches/jws-npm-4.0.1-0d8c257cbe.patch" + yazl: "npm:^3.3.1" + bin: + esrp-npm-release: bin/esrp-npm-release.js + languageName: unknown + linkType: soft + "@ms-cloudpack/environment@npm:^0.1.5": version: 0.1.5 resolution: "@ms-cloudpack/environment@npm:0.1.5" @@ -2274,6 +2472,13 @@ __metadata: languageName: node linkType: hard +"@nodable/entities@npm:^2.1.0": + version: 2.1.0 + resolution: "@nodable/entities@npm:2.1.0" + checksum: 10c0/5a4cba2b61a5b6c726328b18b1de6d033cae4a658a118644bf31e0bcbda126ea7b69385043dc556cf1ed859b9ca220e82b81b5e5c48ef1b519fb8ec104575dee + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -2587,6 +2792,15 @@ __metadata: languageName: node linkType: hard +"@types/jws@npm:^3.2.11": + version: 3.2.11 + resolution: "@types/jws@npm:3.2.11" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/7f4cfb4c5169893e7b41ecfb2d3b796b8b357b83bf5654caf6e671825c3f4e1861416af6d8d4944c0c8c647624327a22feb99d323163c0aac9b9d6c1767d849a + languageName: node + linkType: hard + "@types/minimatch@npm:^3.0.0, @types/minimatch@npm:^3.0.3": version: 3.0.5 resolution: "@types/minimatch@npm:3.0.5" @@ -2682,6 +2896,15 @@ __metadata: languageName: node linkType: hard +"@types/yazl@npm:^3.3.1": + version: 3.3.1 + resolution: "@types/yazl@npm:3.3.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/bc72a56c88d99021ecb80ee9c0a2eb3dceb5bec522c20fd4e160d5c52dcc156c37b2e956a05d54e89e7ececba82aca07a361a9e8f66e5ebbbfcdcd8d2c0dfc0e + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:8.59.1": version: 8.59.1 resolution: "@typescript-eslint/eslint-plugin@npm:8.59.1" @@ -2817,6 +3040,17 @@ __metadata: languageName: node linkType: hard +"@typespec/ts-http-runtime@npm:^0.3.0, @typespec/ts-http-runtime@npm:^0.3.4": + version: 0.3.5 + resolution: "@typespec/ts-http-runtime@npm:0.3.5" + dependencies: + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/3db3119f8d48e6a59dd9aec776a7d37b89d799f0cd201f11b47109df7712382174836534e7d6de5247032a431344b2965481922c90d0dbed85c92f1235aec088 + languageName: node + linkType: hard + "@ungap/structured-clone@npm:^1.3.0": version: 1.3.0 resolution: "@ungap/structured-clone@npm:1.3.0" @@ -3383,6 +3617,13 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 10c0/c2c9ab7599692d594b6a161559ada307b7a624fa4c7b03e3afdb5a5e31cd0e53269115b620fcab024c5ac6a6f37fa5eb2e004f076ad30f5f7e6b8b671f7b35fe + languageName: node + linkType: hard + "ajv@npm:^6.14.0": version: 6.15.0 resolution: "ajv@npm:6.15.0" @@ -3926,6 +4167,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:^1.0.0": + version: 1.0.0 + resolution: "buffer-crc32@npm:1.0.0" + checksum: 10c0/8b86e161cee4bb48d5fa622cbae4c18f25e4857e5203b89e23de59e627ab26beb82d9d7999f2b8de02580165f61f83f997beaf02980cdf06affd175b651921ab + languageName: node + linkType: hard + "buffer-equal-constant-time@npm:^1.0.1": version: 1.0.1 resolution: "buffer-equal-constant-time@npm:1.0.1" @@ -5011,7 +5259,7 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.3.0": +"events@npm:^3.0.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 @@ -5187,6 +5435,29 @@ __metadata: languageName: node linkType: hard +"fast-xml-builder@npm:^1.1.5": + version: 1.1.5 + resolution: "fast-xml-builder@npm:1.1.5" + dependencies: + path-expression-matcher: "npm:^1.1.3" + checksum: 10c0/b814ba5559cb3140de46d2846045607ab4d4c0bfc312a49d22c91efb9f7cd7004971314841e5823eeb467a5bf403e3ade8371b7912200e111df027d42ae51715 + languageName: node + linkType: hard + +"fast-xml-parser@npm:^5.5.9": + version: 5.7.2 + resolution: "fast-xml-parser@npm:5.7.2" + dependencies: + "@nodable/entities": "npm:^2.1.0" + fast-xml-builder: "npm:^1.1.5" + path-expression-matcher: "npm:^1.5.0" + strnum: "npm:^2.2.3" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/d48439ce0700add82f5e7c6ccc5a1f06483beb7cd8e88caa83c6406843e52f14988e60d05cbb3a86ffe07e073807674c807e0764d94a280e1c96d7e2011dae8e + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.20.1 resolution: "fastq@npm:1.20.1" @@ -5796,6 +6067,16 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + languageName: node + linkType: hard + "http-signature@npm:~1.4.0": version: 1.4.0 resolution: "http-signature@npm:1.4.0" @@ -5834,6 +6115,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^7.0.0": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:4" + checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac + languageName: node + linkType: hard + "human-signals@npm:^2.1.0": version: 2.1.0 resolution: "human-signals@npm:2.1.0" @@ -6709,7 +7000,7 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:9.0.3": +"jsonwebtoken@npm:9.0.3, jsonwebtoken@npm:^9.0.0": version: 9.0.3 resolution: "jsonwebtoken@npm:9.0.3" dependencies: @@ -6750,7 +7041,7 @@ __metadata: languageName: node linkType: hard -"jws@npm:^4.0.1": +"jws@npm:4.0.1, jws@npm:^4.0.1": version: 4.0.1 resolution: "jws@npm:4.0.1" dependencies: @@ -6760,6 +7051,16 @@ __metadata: languageName: node linkType: hard +"jws@patch:jws@npm%3A4.0.1#~/.yarn/patches/jws-npm-4.0.1-0d8c257cbe.patch": + version: 4.0.1 + resolution: "jws@patch:jws@npm%3A4.0.1#~/.yarn/patches/jws-npm-4.0.1-0d8c257cbe.patch::version=4.0.1&hash=c18357" + dependencies: + jwa: "npm:^2.0.1" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/46d80cb91b5245aa6f8a737f64c7e9480a48684d0a12972d64486f0688d037831c53607cc75ce93d93e02668b177b9ef933f96bc5ffa60c05ce017512dd19809 + languageName: node + linkType: hard + "keyv@npm:^4.0.0, keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -7613,6 +7914,13 @@ __metadata: languageName: node linkType: hard +"path-expression-matcher@npm:^1.1.3, path-expression-matcher@npm:^1.5.0": + version: 1.5.0 + resolution: "path-expression-matcher@npm:1.5.0" + checksum: 10c0/646cb5bc66cd7d809a52288336f3ac1e6223f156fd8e912936e490e590f7f93e8056d4fd25fcbcc7da61bb698fa520112cb050372a3f65e7b79bd4afa0f77610 + languageName: node + linkType: hard + "path-is-absolute@npm:^1.0.0": version: 1.0.1 resolution: "path-is-absolute@npm:1.0.1" @@ -8751,6 +9059,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^2.2.3": + version: 2.2.3 + resolution: "strnum@npm:2.2.3" + checksum: 10c0/1ee78101f1cd73a5b32f63cfd0be501bd246801a002f5987efef903a49e9297d1b63574e302ab3c06ee5e715c524d6cbdfef010e372ec1ea848e0179836cc208 + languageName: node + linkType: hard + "supports-color@npm:^7.1.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0" @@ -9043,7 +9358,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.4.0": +"tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -9708,6 +10023,15 @@ __metadata: languageName: node linkType: hard +"yazl@npm:^3.3.1": + version: 3.3.1 + resolution: "yazl@npm:3.3.1" + dependencies: + buffer-crc32: "npm:^1.0.0" + checksum: 10c0/8290dffd8afc4ea32459c0ce5d7e22af250515aa0170f289cbc896530d3cd40c5be4788b5e3e7aa5cd21889778071f424e1824faadbd257dbd5ff80fb60d1fa6 + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0"