feat: Support usage of AWS Profiles (#1696)

* Support usage of AWS Profiles

* squash merge main updates w feature branch
Squashed commit of the following:

commit ef2df4679f908ff30d5a711258ace2fa906c4bf3
Author: Michael Lehmann <lehmanmj@amazon.com>
Date:   Tue Mar 17 11:24:04 2026 -0700

    dist update

commit db3779a0e9
Author: Jan Feddern <jf@novatec-gmbh.de>
Date:   Sun Dec 21 11:28:36 2025 +0100

    Support usage of AWS Profiles

* chore: Update dist

* consistent outputEnvCredentials

* take out tests temporarily

* chore: Update dist

* debug changes for static creds

* remove debug and only cleanup profile if it was set

* formatting fixes + remove profile from cleanup test

* feat: Support usage of AWS Profiles

Adds a config option to support writing to profile files instead of
exporting environment variables.

Closes #1594. Closes #1586. Closes #112.

* chore: fix failing test case and windows path

* chore: lint project markdown files

* chore: update scripts in package.json and tsconfig update

* make env vars consistent, readme linting

* debug for profile path env vars

* remove debug

* remove profile backups

* error if we try to overwrite

* add option to overwrite existing profiles

* tests for overwrite option

* default to no env vars

* remove default from action file

* add static credential env var support

* validation fix for static creds multi profile

* debug sleep for static creds validation

* wait syntax

* undo sleep for creds validate

* test coverage, readme/action yml updates, validate creds later on self-hosted runner

* security dependency updates

* chore(deps-dev): bump @biomejs/biome from 2.4.8 to 2.4.10 (#1709)

Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.4.8 to 2.4.10.
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump @aws-sdk/credential-provider-env (#1713)

Bumps [@aws-sdk/credential-provider-env](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-env) from 3.972.22 to 3.972.24.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-env/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-env)

---
updated-dependencies:
- dependency-name: "@aws-sdk/credential-provider-env"
  dependency-version: 3.972.24
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore: Update dist

* chore(deps): bump @aws-sdk/client-sts from 3.1015.0 to 3.1020.0 (#1710)

Bumps [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) from 3.1015.0 to 3.1020.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1020.0/clients/client-sts)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-sts"
  dependency-version: 3.1020.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore: Update dist

* fix: do not write empty profile files

Also cleanup fix, additional test, README typo cleanup

* linting fix

* chore: linting fix

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jan Feddern <jf@novatec-gmbh.de>
Co-authored-by: Michael Lehmann <lehmanmj@amazon.com>
Co-authored-by: GitHub Actions <github-aws-sdk-osds-automation@amazon.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Tom Keller
2026-04-03 16:27:00 -07:00
committed by GitHub
parent e6bb6e5ff3
commit a7f0c828ac
23 changed files with 3579 additions and 920 deletions
+5 -1
View File
@@ -14,7 +14,8 @@ import { errorMessage, getBooleanInput } from '../helpers';
export function cleanup() {
// Only attempt to change environment variables if we changed them in the first place
if (getBooleanInput('output-env-credentials', { required: false, default: true })) {
const awsProfile = core.getInput('aws-profile', { required: false });
if (getBooleanInput('output-env-credentials', { required: false, default: !awsProfile })) {
try {
// The GitHub Actions toolkit does not have an option to completely unset
// environment variables, so we overwrite the current value with an empty
@@ -25,6 +26,9 @@ export function cleanup() {
core.exportVariable('AWS_SESSION_TOKEN', '');
core.exportVariable('AWS_DEFAULT_REGION', '');
core.exportVariable('AWS_REGION', '');
if (core.getInput('aws-profile')) {
core.exportVariable('AWS_PROFILE', '');
}
} catch (error) {
core.setFailed(errorMessage(error));
}
+1
View File
@@ -30,6 +30,7 @@ export function translateEnvVariables() {
'SPECIAL_CHARACTERS_WORKAROUND',
'USE_EXISTING_CREDENTIALS',
'NO_PROXY',
'OVERWRITE_AWS_PROFILE',
];
// Treat HTTPS_PROXY as HTTP_PROXY. Precedence is HTTPS_PROXY > HTTP_PROXY
if (process.env.HTTPS_PROXY) process.env.HTTP_PROXY = process.env.HTTPS_PROXY;
+46 -3
View File
@@ -14,6 +14,7 @@ import {
unsetCredentials,
verifyKeys,
} from './helpers';
import { writeProfileFiles } from './profileManager';
const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds)
const ROLE_SESSION_NAME = 'GitHubActions';
@@ -29,6 +30,8 @@ export async function run() {
const sessionTokenInput = core.getInput('aws-session-token', { required: false });
const SessionToken = sessionTokenInput === '' ? undefined : sessionTokenInput;
const region = core.getInput('aws-region', { required: true });
const awsProfile = core.getInput('aws-profile', { required: false });
const overwriteAwsProfile = getBooleanInput('overwrite-aws-profile', { required: false });
const roleToAssume = core.getInput('role-to-assume', { required: false });
const audience = core.getInput('audience', { required: false });
const maskAccountId = getBooleanInput('mask-aws-account-id', { required: false });
@@ -46,7 +49,9 @@ export async function run() {
});
const roleChaining = getBooleanInput('role-chaining', { required: false });
const outputCredentials = getBooleanInput('output-credentials', { required: false });
const outputEnvCredentials = getBooleanInput('output-env-credentials', { required: false, default: true });
// Default to always outputting environment credentials unless profile is specified. If profile is specified, default to
// no environment credentials (but still output them if the user specifically requests it).
const outputEnvCredentials = getBooleanInput('output-env-credentials', { required: false, default: !awsProfile });
const unsetCurrentCredentials = getBooleanInput('unset-current-credentials', { required: false });
let disableRetry = getBooleanInput('disable-retry', { required: false });
const specialCharacterWorkaround = getBooleanInput('special-characters-workaround', { required: false });
@@ -118,6 +123,7 @@ export async function run() {
if (!region.match(REGION_REGEX)) {
throw new Error(`Region is not valid: ${region}`);
}
exportRegion(region, outputEnvCredentials);
// Instantiate credentials client
@@ -165,6 +171,12 @@ export async function run() {
// the source credentials to already be masked as secrets
// in any error messages.
exportCredentials({ AccessKeyId, SecretAccessKey, SessionToken }, outputCredentials, outputEnvCredentials);
// If using IAM User Credentials, write to profile now so that the assumeRole call can succeed (and also for
// credential validation before role assumption).
if (awsProfile) {
writeProfileFiles(awsProfile, { AccessKeyId, SecretAccessKey, SessionToken }, region, overwriteAwsProfile);
}
} else if (!webIdentityTokenFile && !roleChaining) {
// Proceed only if credentials can be picked up
await credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds);
@@ -178,7 +190,6 @@ export async function run() {
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
}
// Get role credentials if configured to do so
if (roleToAssume) {
let roleCredentials: AssumeRoleCommandOutput;
@@ -210,7 +221,9 @@ export async function run() {
// First: self-hosted runners. If the GITHUB_ACTIONS environment variable
// is set to `true` then we are NOT in a self-hosted runner.
// Second: Customer provided credentials manually (IAM User keys stored in GH Secrets)
if (!process.env.GITHUB_ACTIONS || AccessKeyId) {
// If we are using a profile, don't validate credentials yet (since they most likely won't be in the environment).
// Wait until after creds are written to the profile file to try validation.
if ((!process.env.GITHUB_ACTIONS || AccessKeyId) && !awsProfile) {
await credentialsClient.validateCredentials(
roleCredentials.Credentials?.AccessKeyId,
roleChaining,
@@ -220,8 +233,38 @@ export async function run() {
if (outputEnvCredentials) {
await exportAccountId(credentialsClient, maskAccountId);
}
// Write profile files if profile mode is enabled
if (awsProfile) {
if (!roleCredentials.Credentials) {
throw new Error('AssumeRole call succeeded but returned no credentials');
}
// If user provided IAM User Credentials and then we assumed a role, overwrite the profile file to add
// the session token. (this only overwrites the profile within a single run of the action).
// We then validate the credentials to make sure they work.
if (AccessKeyId || !process.env.GITHUB_ACTIONS) {
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, true);
await credentialsClient.validateCredentials(
roleCredentials.Credentials.AccessKeyId,
roleChaining,
expectedAccountIds,
);
} else {
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, overwriteAwsProfile);
}
if (outputEnvCredentials) {
core.exportVariable('AWS_PROFILE', awsProfile);
}
}
} else {
core.info('Proceeding with IAM user credentials');
if (awsProfile) {
if (outputEnvCredentials) {
core.exportVariable('AWS_PROFILE', awsProfile);
}
}
}
// Clear timeout on successful completion
+214
View File
@@ -0,0 +1,214 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import * as core from '@actions/core';
import type { Credentials } from '@aws-sdk/client-sts';
/**
* Parse an INI-format string into a nested object.
* Preserves literal section names (e.g. "profile dev" stays as-is).
*/
export function parseIni(iniData: string): Record<string, Record<string, string>> {
const result: Record<string, Record<string, string>> = {};
let currentSection: string | undefined;
for (const line of iniData.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) {
continue;
}
const sectionMatch = trimmed.match(/^\[([^\]]*)\]$/);
if (sectionMatch) {
currentSection = sectionMatch[1] as string;
if (currentSection === '__proto__') {
currentSection = undefined;
continue;
}
result[currentSection] = result[currentSection] || {};
continue;
}
if (currentSection) {
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (key !== '__proto__') {
const section = result[currentSection];
if (section) {
section[key] = value;
}
}
}
}
}
return result;
}
/**
* Serialize a nested object into INI-format string.
*/
export function stringifyIni(data: Record<string, Record<string, string>>): string {
const sections: string[] = [];
for (const [sectionName, sectionData] of Object.entries(data)) {
const lines: string[] = [`[${sectionName}]`];
for (const [key, value] of Object.entries(sectionData)) {
lines.push(`${key} = ${value}`);
}
sections.push(lines.join('\n'));
}
return `${sections.join('\n\n')}\n`;
}
interface ProfileFilePaths {
credentials: string;
config: string;
}
/**
* Get the file paths for AWS credentials and config files
* Respects AWS_SHARED_CREDENTIALS_FILE and AWS_CONFIG_FILE environment variables
*/
export function getProfileFilePaths(): ProfileFilePaths {
const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE || path.join(os.homedir(), '.aws', 'credentials');
const configPath = process.env.AWS_CONFIG_FILE || path.join(os.homedir(), '.aws', 'config');
return {
credentials: credentialsPath,
config: configPath,
};
}
/**
* Ensure the AWS directory exists with secure permissions
* Creates the directory with 700 permissions (rwx for owner only)
*/
export function ensureAwsDirectoryExists(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
core.debug(`Creating directory: ${dir}`);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
/**
* Validate profile name format
* Profile names must be non-empty, contain no whitespace, brackets, or path separators
*/
export function validateProfileName(profileName: string): void {
if (!profileName || profileName.trim() === '') {
throw new Error('aws-profile must not be empty');
}
if (/\s/.test(profileName)) {
throw new Error('aws-profile must not contain whitespace');
}
// INI section names can't contain brackets
if (/[[\]]/.test(profileName)) {
throw new Error('aws-profile must not contain brackets');
}
// Prevent path traversal
if (profileName.includes('/') || profileName.includes('\\')) {
throw new Error('aws-profile must not contain path separators');
}
}
/**
* Merge a profile section into an INI file
* Reads existing file, updates the specified section, and writes back
*/
export function mergeProfileSection(
filePath: string,
sectionName: string,
data: Record<string, string>,
overwriteAwsProfile: boolean,
): void {
let existingContent: Record<string, Record<string, string>> = {};
// Read existing file if it exists
if (fs.existsSync(filePath)) {
core.debug(`Reading existing file: ${filePath}`);
const fileContent = fs.readFileSync(filePath, 'utf-8');
existingContent = parseIni(fileContent);
}
if (existingContent[sectionName] && !overwriteAwsProfile) {
throw new Error(
`Profile with name "${sectionName}" already exists. Please use the overwrite-aws-profile input if you want to overwrite existing profiles.`,
);
}
// Merge: update existing profile or add new one
existingContent[sectionName] = data;
const content = stringifyIni(existingContent);
core.debug(`Writing profile to ${filePath}`);
fs.writeFileSync(filePath, content, { mode: 0o600 });
}
/**
* Write AWS profile files with credentials and configuration
* This is the main entry point for profile file operations
*
* @param profileName - Name of the AWS profile to configure
* @param credentials - AWS credentials (access key, secret key, session token)
* @param region - AWS region
*/
export function writeProfileFiles(
profileName: string,
credentials: Partial<Credentials>,
region: string,
overwriteAwsProfile: boolean,
): void {
try {
// Validate profile name
validateProfileName(profileName);
const paths = getProfileFilePaths();
// Ensure .aws directory exists
ensureAwsDirectoryExists(paths.credentials);
ensureAwsDirectoryExists(paths.config);
// Prepare credentials data
const credentialsData: Record<string, string> = {};
if (credentials.AccessKeyId) {
credentialsData.aws_access_key_id = credentials.AccessKeyId;
}
if (credentials.SecretAccessKey) {
credentialsData.aws_secret_access_key = credentials.SecretAccessKey;
}
if (credentials.SessionToken) {
credentialsData.aws_session_token = credentials.SessionToken;
}
// Credentials file uses [profileName] syntax
const credsSectionName = profileName;
// Config file uses [profile profileName] syntax, except for 'default'
const configSectionName = profileName === 'default' ? 'default' : `profile ${profileName}`;
// Prepare config data
const configData: Record<string, string> = {
region: region,
};
// Write to credentials file
core.info(`Writing credentials to profile: ${profileName}`);
mergeProfileSection(paths.credentials, credsSectionName, credentialsData, overwriteAwsProfile);
// Write to config file
core.info(`Writing config to profile: ${profileName}`);
mergeProfileSection(paths.config, configSectionName, configData, overwriteAwsProfile);
core.info(`✓ Successfully configured AWS profile: ${profileName}`);
} catch (error) {
throw new Error(
`Failed to write AWS profile '${profileName}': ${error instanceof Error ? error.message : String(error)}`,
);
}
}