Add support for optional architecture input for cross-architecture .NET installs (#700)

* fix basic validation with npm command

* Revert "fix basic validation with npm command"

This reverts commit 27a0803a2a.

* add architecture support

* updated installdir logic

* update architecture resolution

* update normalizeArch
This commit is contained in:
priya-kinthali
2026-02-27 00:47:54 +05:30
committed by GitHub
parent 16c7b3c2fa
commit 02574b18e2
8 changed files with 401 additions and 8 deletions

View File

@@ -623,3 +623,38 @@ jobs:
- name: Verify dotnet
shell: pwsh
run: __tests__/verify-dotnet.ps1 -Patterns "^9\.0"
test-setup-with-architecture-input:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
arch: [x64, arm64]
exclude:
- os: windows-latest
arch: arm64
- os: ubuntu-latest
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Clear toolcache
shell: pwsh
run: __tests__/clear-toolcache.ps1 ${{ runner.os }}
- name: Setup dotnet (${{ matrix.arch }})
uses: ./
with:
dotnet-version: |
8.0.416
8.0.x
9.0.308
10.0.101
architecture: ${{ matrix.arch }}
- name: Verify dotnet
shell: pwsh
run: __tests__/verify-dotnet.ps1 -Patterns "^8.0.416$", "^9.0.308$", "^10.0.101$", "^8.0"

View File

@@ -59,6 +59,23 @@ The `dotnet-version` input supports following syntax:
- **A.B.Cxx** (e.g. 8.0.4xx) - available since `.NET 5.0` release. Installs the latest version of the specific SDK release, including prerelease versions (preview, rc).
## Using the `architecture` input
Using the architecture input, it is possible to specify the required .NET SDK architecture. Possible values: `x64`, `x86`, `arm64`, `amd64`, `arm`, `s390x`, `ppc64le`, `riscv64`. If the input is not specified, the architecture defaults to the host OS architecture (not all of the architectures are available on all platforms).
**Example: Install multiple SDK versions for a specific architecture**
```yml
steps:
- uses: actions/checkout@v6
- name: Setup dotnet (x86)
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
9.0.x
architecture: x86
- run: dotnet build <my project>
```
## Using the `dotnet-quality` input
This input sets up the action to install the latest build of the specified quality in the channel. The possible values of `dotnet-quality` are: **daily**, **signed**, **validated**, **preview**, **ga**.

View File

@@ -2,6 +2,7 @@ import each from 'jest-each';
import semver from 'semver';
import fs from 'fs';
import fspromises from 'fs/promises';
import os from 'os';
import * as exec from '@actions/exec';
import * as core from '@actions/core';
import * as io from '@actions/io';
@@ -327,6 +328,143 @@ describe('installer tests', () => {
);
});
}
it(`should supply 'architecture' argument to the installation script when architecture is provided`, async () => {
const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions;
const inputArchitecture = 'x64';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => {
return Promise.resolve({
exitCode: 0,
stdout: `${stdout}`,
stderr: ''
});
});
maxSatisfyingSpy.mockImplementation(() => inputVersion);
const dotnetInstaller = new installer.DotnetCoreInstaller(
inputVersion,
inputQuality,
inputArchitecture
);
await dotnetInstaller.installDotnet();
const callIndex = 1;
const scriptArguments = (
getExecOutputSpy.mock.calls[callIndex][1] as string[]
).join(' ');
const expectedArgument = IS_WINDOWS
? `-Architecture ${inputArchitecture}`
: `--architecture ${inputArchitecture}`;
expect(scriptArguments).toContain(expectedArgument);
});
it(`should NOT supply 'architecture' argument when architecture is not provided`, async () => {
const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions;
const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => {
return Promise.resolve({
exitCode: 0,
stdout: `${stdout}`,
stderr: ''
});
});
maxSatisfyingSpy.mockImplementation(() => inputVersion);
const dotnetInstaller = new installer.DotnetCoreInstaller(
inputVersion,
inputQuality
);
await dotnetInstaller.installDotnet();
const callIndex = 1;
const scriptArguments = (
getExecOutputSpy.mock.calls[callIndex][1] as string[]
).join(' ');
expect(scriptArguments).not.toContain('--architecture');
expect(scriptArguments).not.toContain('-Architecture');
});
it(`should supply 'install-dir' with arch subdirectory for cross-arch install`, async () => {
const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions;
const inputArchitecture = 'x64';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => {
return Promise.resolve({
exitCode: 0,
stdout: `${stdout}`,
stderr: ''
});
});
maxSatisfyingSpy.mockImplementation(() => inputVersion);
// Mock os.arch() to return a different arch to simulate cross-arch
const archSpy = jest.spyOn(os, 'arch').mockReturnValue('arm64');
const dotnetInstaller = new installer.DotnetCoreInstaller(
inputVersion,
inputQuality,
inputArchitecture
);
await dotnetInstaller.installDotnet();
const callIndex = 1;
const scriptArguments = (
getExecOutputSpy.mock.calls[callIndex][1] as string[]
).join(' ');
const expectedInstallDirFlag = IS_WINDOWS
? '-InstallDir'
: '--install-dir';
expect(scriptArguments).toContain(expectedInstallDirFlag);
expect(scriptArguments).toContain(inputArchitecture);
archSpy.mockRestore();
});
it(`should NOT supply 'install-dir' when architecture matches runner's native arch`, async () => {
const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions;
const nativeArch = os.arch().toLowerCase();
const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => {
return Promise.resolve({
exitCode: 0,
stdout: `${stdout}`,
stderr: ''
});
});
maxSatisfyingSpy.mockImplementation(() => inputVersion);
const dotnetInstaller = new installer.DotnetCoreInstaller(
inputVersion,
inputQuality,
nativeArch
);
await dotnetInstaller.installDotnet();
const callIndex = 1;
const scriptArguments = (
getExecOutputSpy.mock.calls[callIndex][1] as string[]
).join(' ');
expect(scriptArguments).not.toContain('--install-dir');
expect(scriptArguments).not.toContain('-InstallDir');
});
});
describe('addToPath() tests', () => {
@@ -346,6 +484,32 @@ describe('installer tests', () => {
});
});
describe('normalizeArch() tests', () => {
it(`should normalize 'amd64' to 'x64'`, () => {
expect(installer.normalizeArch('amd64')).toBe('x64');
});
it(`should normalize 'AMD64' to 'x64' (case-insensitive)`, () => {
expect(installer.normalizeArch('AMD64')).toBe('x64');
});
it(`should pass through 'x64' unchanged`, () => {
expect(installer.normalizeArch('x64')).toBe('x64');
});
it(`should pass through 'arm64' unchanged`, () => {
expect(installer.normalizeArch('arm64')).toBe('arm64');
});
it(`should lowercase 'ARM64'`, () => {
expect(installer.normalizeArch('ARM64')).toBe('arm64');
});
it(`should pass through 'x86' unchanged`, () => {
expect(installer.normalizeArch('x86')).toBe('x86');
});
});
describe('DotnetVersionResolver tests', () => {
describe('createDotnetVersion() tests', () => {
each([

View File

@@ -2,7 +2,7 @@ import * as core from '@actions/core';
import fs from 'fs';
import semver from 'semver';
import * as auth from '../src/authutil';
import os from 'os';
import * as setup from '../src/setup-dotnet';
import {DotnetCoreInstaller, DotnetInstallDir} from '../src/installer';
import * as cacheUtils from '../src/cache-utils';
@@ -221,5 +221,40 @@ describe('setup-dotnet tests', () => {
await setup.run();
expect(restoreCacheSpy).not.toHaveBeenCalled();
});
it('should pass valid architecture input to DotnetCoreInstaller', async () => {
inputs['dotnet-version'] = ['10.0.101'];
inputs['dotnet-quality'] = '';
inputs['architecture'] = os.arch().toLowerCase();
installDotnetSpy.mockImplementation(() => Promise.resolve(''));
await setup.run();
expect(installDotnetSpy).toHaveBeenCalledTimes(1);
expect(DotnetInstallDir.addToPath).toHaveBeenCalledTimes(1);
});
it('should work with empty architecture input for auto-detection', async () => {
inputs['dotnet-version'] = ['10.0.101'];
inputs['dotnet-quality'] = '';
inputs['architecture'] = '';
installDotnetSpy.mockImplementation(() => Promise.resolve(''));
await setup.run();
expect(installDotnetSpy).toHaveBeenCalledTimes(1);
expect(DotnetInstallDir.addToPath).toHaveBeenCalledTimes(1);
});
it('should fail the action if unsupported architecture is provided', async () => {
inputs['dotnet-version'] = ['10.0.101'];
inputs['dotnet-quality'] = '';
inputs['architecture'] = 'x688';
const expectedErrorMessage = `Value 'x688' is not supported for the 'architecture' option. Supported values are: x64, x86, arm64, amd64, arm, s390x, ppc64le, riscv64.`;
await setup.run();
expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage);
});
});
});

View File

@@ -27,6 +27,9 @@ inputs:
workloads:
description: 'Optional SDK workloads to install for additional platform support. Examples: wasm-tools, maui, aspire.'
required: false
architecture:
description: 'Optional architecture for the .NET install. Supported values: x64, x86, arm64, amd64, arm, s390x, ppc64le, riscv64. If not set, the installer auto-detects the current system architecture.'
required: false
outputs:
cache-hit:
description: 'A boolean value to indicate if a cache was hit.'

65
dist/setup/index.js vendored
View File

@@ -54714,6 +54714,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.DotnetCoreInstaller = exports.DotnetInstallDir = exports.DotnetInstallScript = exports.DotnetVersionResolver = void 0;
exports.normalizeArch = normalizeArch;
// Load tempDirectory before it gets wiped by tool-cache
const core = __importStar(__nccwpck_require__(42186));
const exec = __importStar(__nccwpck_require__(71514));
@@ -54859,6 +54860,13 @@ class DotnetInstallScript {
this.scriptArguments.push(...args);
return this;
}
// When architecture is empty/undefined, the installer auto-detects the current runner architecture.
useArchitecture(architecture) {
if (!architecture)
return this;
this.useArguments(utils_1.IS_WINDOWS ? '-Architecture' : '--architecture', architecture);
return this;
}
useVersion(dotnetVersion, quality) {
if (dotnetVersion.type) {
this.useArguments(dotnetVersion.type, dotnetVersion.value);
@@ -54907,30 +54915,53 @@ class DotnetInstallDir {
}
}
exports.DotnetInstallDir = DotnetInstallDir;
function normalizeArch(arch) {
switch (arch.toLowerCase()) {
case 'amd64':
return 'x64';
case 'ia32':
return 'x86';
default:
return arch.toLowerCase();
}
}
class DotnetCoreInstaller {
version;
quality;
architecture;
static {
DotnetInstallDir.setEnvironmentVariable();
}
constructor(version, quality) {
constructor(version, quality, architecture) {
this.version = version;
this.quality = quality;
this.architecture = architecture;
}
async installDotnet() {
const versionResolver = new DotnetVersionResolver(this.version);
const dotnetVersion = await versionResolver.createDotnetVersion();
const architectureArguments = this.architecture &&
normalizeArch(this.architecture) !== normalizeArch(os_1.default.arch())
? [
utils_1.IS_WINDOWS ? '-InstallDir' : '--install-dir',
utils_1.IS_WINDOWS
? `"${path_1.default.join(DotnetInstallDir.dirPath, this.architecture)}"`
: path_1.default.join(DotnetInstallDir.dirPath, this.architecture)
]
: [];
/**
* Install dotnet runitme first in order to get
* Install dotnet runtime first in order to get
* the latest stable version of dotnet CLI
*/
const runtimeInstallOutput = await new DotnetInstallScript()
.useArchitecture(this.architecture)
// If dotnet CLI is already installed - avoid overwriting it
.useArguments(utils_1.IS_WINDOWS ? '-SkipNonVersionedFiles' : '--skip-non-versioned-files')
// Install only runtime + CLI
.useArguments(utils_1.IS_WINDOWS ? '-Runtime' : '--runtime', 'dotnet')
// Use latest stable version
.useArguments(utils_1.IS_WINDOWS ? '-Channel' : '--channel', 'LTS')
.useArguments(...architectureArguments)
.execute();
if (runtimeInstallOutput.exitCode) {
/**
@@ -54944,10 +54975,12 @@ class DotnetCoreInstaller {
* dotnet CLI
*/
const dotnetInstallOutput = await new DotnetInstallScript()
.useArchitecture(this.architecture)
// Don't overwrite CLI because it should be already installed
.useArguments(utils_1.IS_WINDOWS ? '-SkipNonVersionedFiles' : '--skip-non-versioned-files')
// Use version provided by user
.useVersion(dotnetVersion, this.quality)
.useArguments(...architectureArguments)
.execute();
if (dotnetInstallOutput.exitCode) {
throw new Error(`Failed to install dotnet, exit code: ${dotnetInstallOutput.exitCode}. ${dotnetInstallOutput.stderr}`);
@@ -55018,6 +55051,7 @@ const installer_1 = __nccwpck_require__(12574);
const fs = __importStar(__nccwpck_require__(57147));
const path_1 = __importDefault(__nccwpck_require__(71017));
const semver_1 = __importDefault(__nccwpck_require__(11383));
const os_1 = __importDefault(__nccwpck_require__(22037));
const auth = __importStar(__nccwpck_require__(17573));
const cache_utils_1 = __nccwpck_require__(41678);
const cache_restore_1 = __nccwpck_require__(19517);
@@ -55030,6 +55064,16 @@ const qualityOptions = [
'preview',
'ga'
];
const supportedArchitectures = [
'x64',
'x86',
'arm64',
'amd64',
'arm',
's390x',
'ppc64le',
'riscv64'
];
async function run() {
try {
//
@@ -55043,6 +55087,7 @@ async function run() {
//
const versions = core.getMultilineInput('dotnet-version');
const installedDotnetVersions = [];
const architecture = getArchitectureInput();
const globalJsonFileInput = core.getInput('global-json-file');
if (globalJsonFileInput) {
const globalJsonPath = path_1.default.resolve(process.cwd(), globalJsonFileInput);
@@ -55070,10 +55115,14 @@ async function run() {
let dotnetInstaller;
const uniqueVersions = new Set(versions);
for (const version of uniqueVersions) {
dotnetInstaller = new installer_1.DotnetCoreInstaller(version, quality);
dotnetInstaller = new installer_1.DotnetCoreInstaller(version, quality, architecture);
const installedVersion = await dotnetInstaller.installDotnet();
installedDotnetVersions.push(installedVersion);
}
if (architecture &&
(0, installer_1.normalizeArch)(architecture) !== (0, installer_1.normalizeArch)(os_1.default.arch())) {
process.env['DOTNET_INSTALL_DIR'] = path_1.default.join(installer_1.DotnetInstallDir.dirPath, architecture);
}
installer_1.DotnetInstallDir.addToPath();
const workloadsInput = core.getInput('workloads');
if (workloadsInput) {
@@ -55111,6 +55160,16 @@ async function run() {
core.setFailed(error.message);
}
}
function getArchitectureInput() {
const raw = (core.getInput('architecture') || '').trim();
if (!raw)
return '';
const normalized = raw.toLowerCase();
if (supportedArchitectures.includes(normalized)) {
return (0, installer_1.normalizeArch)(normalized);
}
throw new Error(`Value '${raw}' is not supported for the 'architecture' option. Supported values are: ${supportedArchitectures.join(', ')}.`);
}
function getVersionFromGlobalJson(globalJsonPath) {
let version = '';
const globalJson = json5_1.default.parse(

View File

@@ -184,6 +184,16 @@ export class DotnetInstallScript {
return this;
}
// When architecture is empty/undefined, the installer auto-detects the current runner architecture.
public useArchitecture(architecture?: string) {
if (!architecture) return this;
this.useArguments(
IS_WINDOWS ? '-Architecture' : '--architecture',
architecture
);
return this;
}
public useVersion(dotnetVersion: DotnetVersion, quality?: QualityOptions) {
if (dotnetVersion.type) {
this.useArguments(dotnetVersion.type, dotnetVersion.value);
@@ -250,6 +260,17 @@ export abstract class DotnetInstallDir {
}
}
export function normalizeArch(arch: string): string {
switch (arch.toLowerCase()) {
case 'amd64':
return 'x64';
case 'ia32':
return 'x86';
default:
return arch.toLowerCase();
}
}
export class DotnetCoreInstaller {
static {
DotnetInstallDir.setEnvironmentVariable();
@@ -257,18 +278,30 @@ export class DotnetCoreInstaller {
constructor(
private version: string,
private quality: QualityOptions
private quality: QualityOptions,
private architecture?: string
) {}
public async installDotnet(): Promise<string | null> {
const versionResolver = new DotnetVersionResolver(this.version);
const dotnetVersion = await versionResolver.createDotnetVersion();
const architectureArguments =
this.architecture &&
normalizeArch(this.architecture) !== normalizeArch(os.arch())
? [
IS_WINDOWS ? '-InstallDir' : '--install-dir',
IS_WINDOWS
? `"${path.join(DotnetInstallDir.dirPath, this.architecture)}"`
: path.join(DotnetInstallDir.dirPath, this.architecture)
]
: [];
/**
* Install dotnet runitme first in order to get
* Install dotnet runtime first in order to get
* the latest stable version of dotnet CLI
*/
const runtimeInstallOutput = await new DotnetInstallScript()
.useArchitecture(this.architecture)
// If dotnet CLI is already installed - avoid overwriting it
.useArguments(
IS_WINDOWS ? '-SkipNonVersionedFiles' : '--skip-non-versioned-files'
@@ -277,6 +310,7 @@ export class DotnetCoreInstaller {
.useArguments(IS_WINDOWS ? '-Runtime' : '--runtime', 'dotnet')
// Use latest stable version
.useArguments(IS_WINDOWS ? '-Channel' : '--channel', 'LTS')
.useArguments(...architectureArguments)
.execute();
if (runtimeInstallOutput.exitCode) {
@@ -294,12 +328,14 @@ export class DotnetCoreInstaller {
* dotnet CLI
*/
const dotnetInstallOutput = await new DotnetInstallScript()
.useArchitecture(this.architecture)
// Don't overwrite CLI because it should be already installed
.useArguments(
IS_WINDOWS ? '-SkipNonVersionedFiles' : '--skip-non-versioned-files'
)
// Use version provided by user
.useVersion(dotnetVersion, this.quality)
.useArguments(...architectureArguments)
.execute();
if (dotnetInstallOutput.exitCode) {

View File

@@ -1,9 +1,14 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import {DotnetCoreInstaller, DotnetInstallDir} from './installer';
import {
DotnetCoreInstaller,
DotnetInstallDir,
normalizeArch
} from './installer';
import * as fs from 'fs';
import path from 'path';
import semver from 'semver';
import os from 'os';
import * as auth from './authutil';
import {isCacheFeatureAvailable} from './cache-utils';
import {restoreCache} from './cache-restore';
@@ -17,6 +22,17 @@ const qualityOptions = [
'preview',
'ga'
] as const;
const supportedArchitectures = [
'x64',
'x86',
'arm64',
'amd64',
'arm',
's390x',
'ppc64le',
'riscv64'
] as const;
type SupportedArchitecture = (typeof supportedArchitectures)[number];
export type QualityOptions = (typeof qualityOptions)[number];
@@ -33,6 +49,7 @@ export async function run() {
//
const versions = core.getMultilineInput('dotnet-version');
const installedDotnetVersions: (string | null)[] = [];
const architecture = getArchitectureInput();
const globalJsonFileInput = core.getInput('global-json-file');
if (globalJsonFileInput) {
@@ -70,10 +87,23 @@ export async function run() {
let dotnetInstaller: DotnetCoreInstaller;
const uniqueVersions = new Set<string>(versions);
for (const version of uniqueVersions) {
dotnetInstaller = new DotnetCoreInstaller(version, quality);
dotnetInstaller = new DotnetCoreInstaller(
version,
quality,
architecture
);
const installedVersion = await dotnetInstaller.installDotnet();
installedDotnetVersions.push(installedVersion);
}
if (
architecture &&
normalizeArch(architecture) !== normalizeArch(os.arch())
) {
process.env['DOTNET_INSTALL_DIR'] = path.join(
DotnetInstallDir.dirPath,
architecture
);
}
DotnetInstallDir.addToPath();
const workloadsInput = core.getInput('workloads');
@@ -119,6 +149,20 @@ export async function run() {
}
}
function getArchitectureInput(): SupportedArchitecture | '' {
const raw = (core.getInput('architecture') || '').trim();
if (!raw) return '';
const normalized = raw.toLowerCase();
if ((supportedArchitectures as readonly string[]).includes(normalized)) {
return normalizeArch(normalized) as SupportedArchitecture;
}
throw new Error(
`Value '${raw}' is not supported for the 'architecture' option. Supported values are: ${supportedArchitectures.join(
', '
)}.`
);
}
function getVersionFromGlobalJson(globalJsonPath: string): string {
let version = '';
const globalJson = JSON5.parse(