Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 93 additions & 53 deletions .ado/jobs/npm-publish.yml
Original file line number Diff line number Diff line change
@@ -1,64 +1,104 @@
jobs:
- job: NPMPublish
displayName: NPM Publish
pool:
name: cxeiss-ubuntu-20-04-large
image: cxe-ubuntu-20-04-1es-pt
os: linux
variables:
- name: BUILDSECMON_OPT_IN
value: true
- job: NpmPack
displayName: NPM Pack
pool:
name: cxeiss-ubuntu-20-04-large
image: cxe-ubuntu-20-04-1es-pt
os: linux
variables:
- name: BUILDSECMON_OPT_IN
value: true
timeoutInMinutes: 90
cancelTimeoutInMinutes: 5
templateContext:
outputs:
- output: pipelineArtifact
condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1'))
targetPath: $(Build.ArtifactStagingDirectory)/npm-packed-tarballs
artifactName: NpmPackedTarballs
steps:
- checkout: self
clean: true
fetchFilter: blob:none
persistCredentials: true

timeoutInMinutes: 90
cancelTimeoutInMinutes: 5
templateContext:
outputs:
- output: pipelineArtifact
targetPath: $(System.DefaultWorkingDirectory)
artifactName: github-npm-js-publish
steps:
- checkout: self
clean: true
fetchFilter: blob:none
persistCredentials: true
- task: UseNode@1
inputs:
version: '22.22.0'
displayName: Use Node.js 22.22.0
Comment on lines +27 to +28

@Saadnajmi Saadnajmi Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Match Node used in microsoft-* prefixed Gtihub Actions files


- task: UseNode@1
inputs:
version: '22.22.0'
displayName: 'Use Node.js 22.22.0'
- template: /.ado/templates/configure-git.yml@self

- template: /.ado/templates/configure-git.yml@self
- script: yarn install
displayName: Install npm dependencies

- script: |
yarn install
displayName: Install npm dependencies
- script: yarn workspaces foreach --all --topological --no-private run build
displayName: Build publishable workspaces

- script: |
node .ado/scripts/configure-publish.mts --verbose --skip-auth
displayName: Verify release config
- script: node .ado/scripts/configure-publish.mts --verbose --skip-auth
name: config
displayName: Verify release config

# Disable Nightly publishing on the main branch
- ${{ if endsWith(variables['Build.SourceBranchName'], '-stable') }}:
- script: |
yarn config set npmPublishAccess public
yarn config set npmPublishRegistry "https://registry.npmjs.org"
yarn config set npmAuthToken $(npmAuthToken)
displayName: Configure yarn for npm publishing
- script: node .ado/scripts/npm-pack.mts --clean "$(Build.ArtifactStagingDirectory)/npm-packed-tarballs"
displayName: Pack npm packages
condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1'))

- script: |
yarn workspaces foreach -vv --all --topological --no-private npm publish --tag $(publishTag) --tolerate-republish
displayName: Publish packages
condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1'))
- job: NpmEsrpRelease
displayName: NPM ESRP Release
dependsOn: NpmPack
condition: and(succeeded(), eq(dependencies.NpmPack.outputs['config.publish_react_native_macos'], '1'))
variables:
- name: publish_react_native_macos
value: $[ dependencies.NpmPack.outputs['config.publish_react_native_macos'] ]
- name: publishTag
value: $[ dependencies.NpmPack.outputs['config.publishTag'] ]
timeoutInMinutes: 30
templateContext:
type: releaseJob
isProduction: true
inputs:
- input: pipelineArtifact
artifactName: NpmPackedTarballs
targetPath: $(Pipeline.Workspace)/npm-packed-tarballs
steps:
- checkout: self
clean: true
fetchFilter: blob:none

- script: |
node .ado/scripts/apply-additional-tags.mjs --tags "$(additionalTags)" --token "$(npmAuthToken)"
displayName: Apply additional dist-tags
condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1'))
- task: UseNode@1
inputs:
version: '22.22.0'
displayName: Use Node.js 22.22.0

- script: node .ado/scripts/npm-pack.mts --no-pack --check-npm "$(Pipeline.Workspace)/npm-packed-tarballs"
displayName: Remove already-published packages

- script: node .ado/scripts/validate-esrp-config.mts
displayName: Validate ESRP onboarding
env:
EsrpConnectedServiceName: $(EsrpConnectedServiceName)
EsrpKeyVaultName: $(EsrpKeyVaultName)
EsrpAuthCertName: $(EsrpAuthCertName)
EsrpSignCertName: $(EsrpSignCertName)
EsrpClientId: $(EsrpClientId)
EsrpTenantId: $(EsrpTenantId)
EsrpOwners: $(EsrpOwners)
EsrpApprovers: $(EsrpApprovers)
publishTag: $(publishTag)

- script: |
yarn config unset npmPublishAccess || true
yarn config unset npmAuthToken || true
yarn config unset npmPublishRegistry || true
displayName: Remove NPM auth configuration
condition: always()
- task: EsrpRelease@11
displayName: ESRP release to npmjs.com
condition: and(succeeded(), eq(variables['HasPackagesToPublish'], 'true'))
inputs:
connectedservicename: $(EsrpConnectedServiceName)
usemanagedidentity: false
keyvaultname: $(EsrpKeyVaultName)
authcertname: $(EsrpAuthCertName)
signcertname: $(EsrpSignCertName)
clientid: $(EsrpClientId)
domaintenantid: $(EsrpTenantId)
contenttype: npm
folderlocation: $(Pipeline.Workspace)/npm-packed-tarballs
productstate: $(publishTag)
owners: $(EsrpOwners)
approvers: $(EsrpApprovers)
Comment on lines +89 to +104

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should just be copied verbatim from https://github.com/microsoft/node-api-dotnet/blob/04a65d25fb7d2031abeab261329a691f14fd6534/.ado/release.yml#L201. We do not define them anywhere and they are mostly magic anyway.

2 changes: 1 addition & 1 deletion .ado/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ extends:
- stage: NPM
dependsOn: []
jobs:
- template: /.ado/jobs/npm-publish.yml@self
- template: /.ado/jobs/npm-publish.yml@self
73 changes: 73 additions & 0 deletions .ado/scripts/__tests__/configure-publish-test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import {execFile as execFileCallback} from 'node:child_process';
import {fileURLToPath} from 'node:url';
import {promisify} from 'node:util';
import {describe, it} from 'node:test';

import {
getAzurePipelineVariableCommands,
getPublishTags,
} from '../configure-publish.mts';

const execFile = promisify(execFileCallback);

describe('configure-publish', () => {
it('emits plain and named-step output variables', () => {
assert.deepEqual(getAzurePipelineVariableCommands('publishTag', 'next'), [
'##vso[task.setvariable variable=publishTag]next',
'##vso[task.setvariable variable=publishTag;isOutput=true]next',
]);
assert.deepEqual(
getAzurePipelineVariableCommands('publish_react_native_macos', '1'),
[
'##vso[task.setvariable variable=publish_react_native_macos]1',
'##vso[task.setvariable variable=publish_react_native_macos;isOutput=true]1',
],
);
});

it('uses one tag per release line', () => {
assert.deepEqual(
getPublishTags(
{state: 'STABLE_IS_LATEST', currentVersion: 83, latestVersion: 83, nextVersion: 84},
'0.83-stable',
),
{npmTags: ['latest']},
);
assert.deepEqual(
getPublishTags(
{state: 'STABLE_IS_OLD', currentVersion: 82, latestVersion: 83, nextVersion: 84},
'0.82-stable',
),
{npmTags: ['0.82-stable']},
);
assert.deepEqual(
getPublishTags(
{state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 84},
'0.84-stable',
),
{npmTags: ['next'], prerelease: 'rc'},
);
assert.deepEqual(
getPublishTags(
{state: 'STABLE_IS_NEW', currentVersion: 84, latestVersion: 83, nextVersion: 83},
'0.84-stable',
'latest',
),
{npmTags: ['latest']},
);
});

it('does not emit publish variables on main', async () => {
const script = fileURLToPath(new URL('../configure-publish.mts', import.meta.url));
const {stdout} = await execFile(process.execPath, [
script,
'--mock-branch',
'main',
'--skip-auth',
]);

assert.match(stdout, /nightly publishing is currently disabled/);
assert.doesNotMatch(stdout, /##vso\[task\.setvariable/);
});
});
153 changes: 153 additions & 0 deletions .ado/scripts/__tests__/npm-pack-test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import {mkdtemp, mkdir, readFile, rm, writeFile} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {describe, it} from 'node:test';

import {
checkPublishedTarballs,
filterPublishedTarballs,
getHasPackagesToPublishCommand,
getPublishableWorkspaces,
getRegistryStatus,
packWorkspaces,
safeTarballName,
type CommandRunner,
} from '../npm-pack.mts';

const temporaryDirectory = () => mkdtemp(join(tmpdir(), 'rnm-npm-pack-'));

describe('npm-pack', () => {
it('emits the Azure package availability variable', () => {
assert.equal(
getHasPackagesToPublishCommand(1),
'##vso[task.setvariable variable=HasPackagesToPublish]true',
);
assert.equal(
getHasPackagesToPublishCommand(0),
'##vso[task.setvariable variable=HasPackagesToPublish]false',
);
});

it('selects only public workspaces and creates safe names', async () => {
const root = await temporaryDirectory();
for (const [directory, manifest] of [
['private', {name: 'private-package', private: true, version: '1.0.0'}],
['public', {name: '@scope/public-package', version: '1.2.3'}],
] as const) {
await mkdir(join(root, 'packages', directory), {recursive: true});
await writeFile(join(root, 'packages', directory, 'package.json'), JSON.stringify(manifest));
}
const run: CommandRunner = async () => ({
stderr: '',
stdout:
'{"location":"packages/private"}\n' +
'{"location":"packages/public"}\n',
});

assert.deepEqual(await getPublishableWorkspaces(root, run), [
{name: '@scope/public-package', version: '1.2.3'},
]);
assert.equal(safeTarballName('@scope/public-package', '1.2.3'), 'scope-public-package-1.2.3.tgz');

const calls: string[][] = [];
const pack: CommandRunner = async (_command, args) => {
calls.push(args);
return args[0] === 'workspaces'
? run(_command, args)
: {stderr: '', stdout: ''};
};
await packWorkspaces(root, join(root, 'output'), pack);
assert.deepEqual(calls.at(-1), [
'workspace',
'@scope/public-package',
'pack',
'--out',
join(root, 'output', 'scope-public-package-1.2.3.tgz'),
]);
});

it('removes only exact versions already published', async () => {
const output = await temporaryDirectory();
const published = join(output, 'published.tgz');
const unpublished = join(output, 'unpublished.tgz');
await writeFile(published, 'published');
await writeFile(unpublished, 'unpublished');
const run: CommandRunner = async (command, args) => {
const file = args[1];
if (command === 'tar') {
return {
stderr: '',
stdout: JSON.stringify({
name: file === published ? 'published' : 'unpublished',
version: '1.0.0',
}),
};
}
if (args[1] === 'published@1.0.0') return {stderr: '', stdout: '"1.0.0"\n'};
const error = new Error('not found') as Error & {stderr: string};
error.stderr = 'npm error code E404';
throw error;
};

assert.deepEqual(await filterPublishedTarballs(output, run), [unpublished]);
await assert.rejects(readFile(published), {code: 'ENOENT'});
assert.equal(await readFile(unpublished, 'utf8'), 'unpublished');
});

it('reports whether filtered tarballs remain', async () => {
const output = await temporaryDirectory();
const unpublished = join(output, 'unpublished.tgz');
await writeFile(unpublished, 'unpublished');
const run: CommandRunner = async (command, args) => {
if (command === 'tar') {
return {
stderr: '',
stdout: JSON.stringify({name: 'unpublished', version: '1.0.0'}),
};
}
const error = new Error('not found') as Error & {stderr: string};
error.stderr = 'npm error code E404';
throw error;
};
const messages: string[] = [];

assert.deepEqual(
await checkPublishedTarballs(output, run, message => messages.push(message)),
[unpublished],
);
assert.deepEqual(messages, [
'Found 1 unpublished package(s)',
'##vso[task.setvariable variable=HasPackagesToPublish]true',
]);

await rm(unpublished);
messages.length = 0;
assert.deepEqual(
await checkPublishedTarballs(output, run, message => messages.push(message)),
[],
);
assert.deepEqual(messages, [
'Found 0 unpublished package(s)',
'##vso[task.setvariable variable=HasPackagesToPublish]false',
]);
});

it('fails on registry errors and malformed responses', async () => {
const serverError: CommandRunner = async () => {
const error = new Error('server error') as Error & {stderr: string};
error.stderr = 'npm error code E500';
throw error;
};
await assert.rejects(getRegistryStatus('package', '1.0.0', serverError), /Failed to query npm/);

const malformed: CommandRunner = async () => ({stderr: '', stdout: 'not-json'});
await assert.rejects(getRegistryStatus('package', '1.0.0', malformed), SyntaxError);

const wrongVersion: CommandRunner = async () => ({stderr: '', stdout: '"2.0.0"'});
await assert.rejects(
getRegistryStatus('package', '1.0.0', wrongVersion),
/unexpected version/,
);
});
});
Loading
Loading