1
0
Fork 0
mirror of synced 2026-06-05 09:25:13 +00:00

feat: add additional session tags by default

Closes #390.
Note that 50 session tags are the AWS default, and this commit changes
our default set from 7 tags to 15 tags. This commit includes logic to
split the tags into "required" vs "overridable". Required tags are this
action's previous defaults and could never be overridden. Overridable
tags are the new set and can be overridden by custom-tags. The action
will not add tags if the addition plus the required plus the user's
custom tags exceed the AWS limit of 50 total tags. This ensures
backwards compat for the tag additions.
This commit is contained in:
Tom Keller 2026-05-13 11:35:45 -07:00
commit 9ca54121eb
6 changed files with 8773 additions and 13670 deletions

View file

@ -350,6 +350,9 @@ definitions][gh-env-vars])
[gh-env-vars]:
https://docs.github.com/en/actions/reference/workflows-and-actions/variables#default-environment-variables
**Protected tags** are always emitted when session tags are used, and cannot be
overridden via `custom-tags`:
| Key | Value |
| ---------- | ----------------- |
| GitHub | "Actions" |
@ -357,14 +360,32 @@ definitions][gh-env-vars])
| Workflow | GITHUB_WORKFLOW |
| Action | GITHUB_ACTION |
| Actor | GITHUB_ACTOR |
| Branch | GITHUB_REF |
| Commit | GITHUB_SHA |
| Branch | GITHUB_REF |
**Overrideable tags** are automatically added to the set of default session
tags but may be overridden via `custom-tags`. AWS has a maximum limit of 50
session tags; tags from this list are dropped in reverse priority order if
your `custom-tags` set plus the protected set exceeds this limit.
| Key | Value | Priority |
| --------------- | ----------------------- | -------- |
| EventName | GITHUB_EVENT_NAME | 1 |
| BaseRef | GITHUB_BASE_REF | 2 |
| HeadRef | GITHUB_HEAD_REF | 3 |
| RefName | GITHUB_REF_NAME | 4 |
| RunId | GITHUB_RUN_ID | 5 |
| RefType | GITHUB_REF_TYPE | 6 |
| Job | GITHUB_JOB | 7 |
| TriggeringActor | GITHUB_TRIGGERING_ACTOR | 8 |
Tags whose source environment variable is unset are omitted (e.g., `BaseRef`
and `HeadRef` are only set on `pull_request` events).
_Note: all tag values must conform to
[the tag requirements](https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html).
Particularly, `GITHUB_WORKFLOW` will be truncated if it's too long. If
`GITHUB_ACTOR` or `GITHUB_WORKFLOW` contain invalid characters, the characters
will be replaced with an '\*'._
Values longer than 256 characters will be truncated, and characters outside the
allowed set will be replaced with an underscore (`_`)._
The action will use session tagging by default unless you are using OIDC.
@ -391,7 +412,9 @@ with:
### Custom session tags
You can add custom session tags using the `custom-tags` input, which accepts a
JSON object. Custom tags cannot override the default tags listed above.
JSON object. Custom tags cannot override protected tags, but they can override
overrideable tags (in which case the overrideable tag's slot is freed for the
next overrideable tag in the priority list, if any).
```yaml
uses: aws-actions/configure-aws-credentials@v6

View file

@ -431,12 +431,11 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-----------
The following npm packages may be included in this product:
The following npm package may be included in this product:
- @aws-crypto/crc32@5.2.0
- @aws-crypto/util@5.2.0
These packages each contain the following license:
This package contains the following license:
Apache License
Version 2.0, January 2004
@ -644,7 +643,7 @@ Apache License
The following npm packages may be included in this product:
- @aws-sdk/client-sts@3.1045.0
- @aws-sdk/client-sts@3.1044.0
- @aws-sdk/util-user-agent-browser@3.972.10
- @aws-sdk/util-user-agent-node@3.973.24
- @smithy/middleware-retry@4.5.7
@ -868,7 +867,7 @@ The following npm packages may be included in this product:
- @aws-sdk/middleware-sdk-s3@3.972.37
- @aws-sdk/middleware-user-agent@3.972.38
- @aws-sdk/signature-v4-multi-region@3.996.25
- @smithy/core@3.24.1
- @smithy/core@3.23.17
- @smithy/invalid-dependency@4.2.14
- @smithy/middleware-serde@4.2.20
- @smithy/protocol-http@5.3.14
@ -1710,8 +1709,8 @@ The following npm packages may be included in this product:
- @smithy/middleware-content-length@4.2.14
- @smithy/middleware-endpoint@4.4.32
- @smithy/middleware-stack@4.2.14
- @smithy/node-http-handler@4.7.1
- @smithy/property-provider@4.3.1
- @smithy/node-http-handler@4.6.1
- @smithy/property-provider@4.2.14
- @smithy/shared-ini-file-loader@4.4.9
- @smithy/signature-v4@5.3.14
- @smithy/util-base64@4.3.2

16702
dist/index.js generated vendored

File diff suppressed because it is too large Load diff

View file

@ -87,6 +87,32 @@ const MAX_TAG_KEY_LENGTH = 128;
const MAX_TAG_VALUE_LENGTH = 256;
const MAX_SESSION_TAGS = 50;
// Identity/audit primitives. Always emitted and cannot be overridden by custom-tags.
const PROTECTED_TAG_SOURCES: ReadonlyArray<{ key: string; envVar: string }> = [
{ key: 'Repository', envVar: 'GITHUB_REPOSITORY' },
{ key: 'Workflow', envVar: 'GITHUB_WORKFLOW' },
{ key: 'Action', envVar: 'GITHUB_ACTION' },
{ key: 'Actor', envVar: 'GITHUB_ACTOR' },
{ key: 'Commit', envVar: 'GITHUB_SHA' },
{ key: 'Branch', envVar: 'GITHUB_REF' },
];
// Convenience metadata. Custom-tags may override (suppresses the default for that key).
// Listed in priority order; lower-priority entries are dropped first if the user's custom-tags
// would push the total above MAX_SESSION_TAGS.
const OVERRIDEABLE_TAG_SOURCES_BY_PRIORITY: ReadonlyArray<{ key: string; envVar: string }> = [
{ key: 'EventName', envVar: 'GITHUB_EVENT_NAME' },
{ key: 'BaseRef', envVar: 'GITHUB_BASE_REF' },
{ key: 'HeadRef', envVar: 'GITHUB_HEAD_REF' },
{ key: 'RefName', envVar: 'GITHUB_REF_NAME' },
{ key: 'RunId', envVar: 'GITHUB_RUN_ID' },
{ key: 'RefType', envVar: 'GITHUB_REF_TYPE' },
{ key: 'Job', envVar: 'GITHUB_JOB' },
{ key: 'TriggeringActor', envVar: 'GITHUB_TRIGGERING_ACTOR' },
];
const PROTECTED_TAG_KEYS = new Set<string>(['GitHub', ...PROTECTED_TAG_SOURCES.map((s) => s.key)]);
export function parseAndValidateCustomTags(customTags: string, existingTags: Tag[]): Tag[] {
let parsed: unknown;
try {
@ -99,7 +125,6 @@ export function parseAndValidateCustomTags(customTags: string, existingTags: Tag
throw new Error('custom-tags: input must be a JSON object (not an array or primitive)');
}
const reservedKeys = new Set(existingTags.map((tag) => tag.Key));
const newTags: Tag[] = [];
for (const [key, value] of Object.entries(parsed)) {
@ -129,9 +154,9 @@ export function parseAndValidateCustomTags(customTags: string, existingTags: Tag
`custom-tags: value for key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
);
}
if (reservedKeys.has(key)) {
if (PROTECTED_TAG_KEYS.has(key)) {
throw new Error(
`custom-tags: key '${key}' conflicts with a default session tag set by this action and cannot be overridden`,
`custom-tags: key '${key}' conflicts with a protected session tag set by this action and cannot be overridden`,
);
}
@ -170,28 +195,32 @@ export async function assumeRole(params: assumeRoleParams) {
throw new Error('Missing required environment variables. Are you running in GitHub Actions?');
}
// Load role session tags
const tagArray: Tag[] = [
{ Key: 'GitHub', Value: 'Actions' },
{ Key: 'Repository', Value: GITHUB_REPOSITORY },
{ Key: 'Workflow', Value: sanitizeGitHubVariables(GITHUB_WORKFLOW) },
{ Key: 'Action', Value: GITHUB_ACTION },
{ Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) },
{ Key: 'Commit', Value: GITHUB_SHA },
];
if (process.env.GITHUB_REF) {
tagArray.push({
Key: 'Branch',
Value: sanitizeGitHubVariables(process.env.GITHUB_REF),
});
// Build session tags. Values are sanitized because the AWS tag value spec is more
// restrictive than permissible characters in environment variables.
const protectedTags: Tag[] = [{ Key: 'GitHub', Value: 'Actions' }];
for (const { key, envVar } of PROTECTED_TAG_SOURCES) {
const value = process.env[envVar];
if (value) {
protectedTags.push({ Key: key, Value: sanitizeGitHubVariables(value) });
}
}
if (customTags) {
const parsed = parseAndValidateCustomTags(customTags, tagArray);
tagArray.push(...parsed);
const parsedCustomTags: Tag[] = customTags ? parseAndValidateCustomTags(customTags, protectedTags) : [];
const customTagKeys = new Set(parsedCustomTags.map((t) => t.Key));
const availableOverrideableSlots = MAX_SESSION_TAGS - protectedTags.length - parsedCustomTags.length;
const overrideableTags: Tag[] = [];
for (const { key, envVar } of OVERRIDEABLE_TAG_SOURCES_BY_PRIORITY) {
if (overrideableTags.length >= availableOverrideableSlots) break;
if (customTagKeys.has(key)) continue;
const value = process.env[envVar];
if (value) {
overrideableTags.push({ Key: key, Value: sanitizeGitHubVariables(value) });
}
}
const tagArray: Tag[] = [...protectedTags, ...overrideableTags, ...parsedCustomTags];
const tags = roleSkipSessionTagging ? undefined : tagArray;
if (!tags) {
core.debug('Role session tagging has been skipped.');

View file

@ -245,6 +245,74 @@ describe('Configure AWS Credentials', {}, () => {
});
});
describe('Default session tags', {}, () => {
beforeEach(() => {
mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
.mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' })
.mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' });
});
it('emits exactly the expected default tag set with no custom-tags', {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
await run();
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
// 7 protected (GitHub + Repository, Workflow, Action, Actor, Commit, Branch)
// + 8 overrideable (EventName, BaseRef, HeadRef, RefName, RunId, RefType, Job, TriggeringActor).
// No custom-tags, all env vars set in mocks.envs → all 15 should be present, nothing else.
expect(tags).toHaveLength(15);
const tagsByKey = Object.fromEntries(tags.map((t) => [t.Key, t.Value]));
expect(tagsByKey).toEqual({
GitHub: 'Actions',
Repository: 'MY-REPOSITORY-NAME',
Workflow: 'MY-WORKFLOW-ID',
Action: 'MY-ACTION-NAME',
Actor: 'MY-USERNAME_bot_',
Commit: 'MY-COMMIT-ID',
Branch: 'refs/pull/42/merge',
EventName: 'pull_request',
BaseRef: 'main',
HeadRef: 'feature-branch',
RefName: 'feature-branch',
RunId: '16412345678',
RefType: 'branch',
Job: 'build',
TriggeringActor: 'MY-USERNAME_bot_',
});
});
it('omits overrideable tags whose env vars are unset', {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
delete process.env.GITHUB_BASE_REF;
delete process.env.GITHUB_HEAD_REF;
delete process.env.GITHUB_TRIGGERING_ACTOR;
await run();
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
const tagKeys = tags.map((t) => t.Key);
expect(tagKeys).not.toContain('BaseRef');
expect(tagKeys).not.toContain('HeadRef');
expect(tagKeys).not.toContain('TriggeringActor');
expect(tagKeys).toContain('EventName');
expect(tagKeys).toContain('RunId');
});
it('sanitizes invalid characters in env-derived tag values', {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
process.env.GITHUB_HEAD_REF = 'feature/has spaces&bad?chars';
await run();
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input).toMatchObject({
Tags: expect.arrayContaining([{ Key: 'HeadRef', Value: 'feature/has spaces_bad_chars' }]),
});
});
it('truncates env-derived tag values longer than 256 characters', {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
process.env.GITHUB_HEAD_REF = 'a'.repeat(300);
await run();
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
const headRef = tags.find((t) => t.Key === 'HeadRef');
expect(headRef?.Value).toHaveLength(256);
});
});
describe('Custom Tags', {}, () => {
beforeEach(() => {
mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
@ -273,6 +341,15 @@ describe('Configure AWS Credentials', {}, () => {
{ Key: 'Action', Value: 'MY-ACTION-NAME' },
{ Key: 'Actor', Value: 'MY-USERNAME_bot_' },
{ Key: 'Commit', Value: 'MY-COMMIT-ID' },
{ Key: 'Branch', Value: 'refs/pull/42/merge' },
{ Key: 'BaseRef', Value: 'main' },
{ Key: 'HeadRef', Value: 'feature-branch' },
{ Key: 'EventName', Value: 'pull_request' },
{ Key: 'RunId', Value: '16412345678' },
{ Key: 'Job', Value: 'build' },
{ Key: 'RefName', Value: 'feature-branch' },
{ Key: 'RefType', Value: 'branch' },
{ Key: 'TriggeringActor', Value: 'MY-USERNAME_bot_' },
{ Key: 'Environment', Value: 'Production' },
{ Key: 'Team', Value: 'DevOps' },
]),
@ -286,11 +363,11 @@ describe('Configure AWS Credentials', {}, () => {
);
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
});
it('rejects custom tags that conflict with default session tags', {}, async () => {
it('rejects custom tags that conflict with protected session tags', {}, async () => {
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_RESERVED_KEY_INPUTS));
await run();
expect(core.setFailed).toHaveBeenCalledWith(
"custom-tags: key 'Repository' conflicts with a default session tag set by this action and cannot be overridden",
"custom-tags: key 'Repository' conflicts with a protected session tag set by this action and cannot be overridden",
);
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
});
@ -320,6 +397,128 @@ describe('Configure AWS Credentials', {}, () => {
await run();
expect(core.warning).toHaveBeenCalledWith(expect.stringContaining("'custom-tags' is set but will be ignored"));
});
it('lets custom tags override overrideable default tag keys', {}, async () => {
vi.mocked(core.getInput).mockImplementation(
mocks.getInput({
...mocks.IAM_ASSUMEROLE_INPUTS,
'custom-tags': JSON.stringify({ EventName: 'workflow_dispatch', BaseRef: 'release/2026' }),
}),
);
await run();
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
const eventNameTags = tags.filter((t) => t.Key === 'EventName');
const baseRefTags = tags.filter((t) => t.Key === 'BaseRef');
expect(eventNameTags).toHaveLength(1);
expect(eventNameTags[0]?.Value).toBe('workflow_dispatch');
expect(baseRefTags).toHaveLength(1);
expect(baseRefTags[0]?.Value).toBe('release/2026');
});
it('rejects custom tags that conflict with the protected Branch tag', {}, async () => {
// Regression guard: Branch was a default before v6.2 and must remain unoverridable.
vi.mocked(core.getInput).mockImplementation(
mocks.getInput({
...mocks.IAM_ASSUMEROLE_INPUTS,
'custom-tags': JSON.stringify({ Branch: 'evil-branch' }),
}),
);
await run();
expect(core.setFailed).toHaveBeenCalledWith(
"custom-tags: key 'Branch' conflicts with a protected session tag set by this action and cannot be overridden",
);
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
});
it('drops lower-priority overrideable tags when custom-tags would exceed the session-tag limit', {}, async () => {
// 7 protected (GitHub + 6 from PROTECTED_TAG_SOURCES) + 40 custom = 47 used → 3 overrideable slots.
// The first 3 overrideable tags by priority are EventName, BaseRef, HeadRef (RefName, RunId, RefType,
// Job, TriggeringActor must be dropped).
const customTagsObj: Record<string, string> = {};
for (let i = 0; i < 40; i++) {
customTagsObj[`Custom${i}`] = `value${i}`;
}
vi.mocked(core.getInput).mockImplementation(
mocks.getInput({
...mocks.IAM_ASSUMEROLE_INPUTS,
'custom-tags': JSON.stringify(customTagsObj),
}),
);
await run();
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
const tagKeys = tags.map((t) => t.Key);
expect(tags).toHaveLength(50);
expect(tagKeys).toContain('Branch');
expect(tagKeys).toContain('EventName');
expect(tagKeys).toContain('BaseRef');
expect(tagKeys).toContain('HeadRef');
expect(tagKeys).not.toContain('RefName');
expect(tagKeys).not.toContain('RunId');
expect(tagKeys).not.toContain('RefType');
expect(tagKeys).not.toContain('Job');
expect(tagKeys).not.toContain('TriggeringActor');
});
it('overridden overrideable tags free a slot for a lower-priority overrideable tag', {}, async () => {
// Same 40-custom-tag scenario as above, but one of the customs overrides BaseRef.
// BaseRef no longer competes for the overrideable budget, so the next-priority overrideable (RefName) gets in.
const customTagsObj: Record<string, string> = { BaseRef: 'release/2026' };
for (let i = 0; i < 39; i++) {
customTagsObj[`Custom${i}`] = `value${i}`;
}
vi.mocked(core.getInput).mockImplementation(
mocks.getInput({
...mocks.IAM_ASSUMEROLE_INPUTS,
'custom-tags': JSON.stringify(customTagsObj),
}),
);
await run();
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
const tagKeys = tags.map((t) => t.Key);
expect(tags).toHaveLength(50);
expect(tagKeys).toContain('Branch');
expect(tagKeys).toContain('EventName');
expect(tagKeys).toContain('BaseRef');
expect(tagKeys).toContain('HeadRef');
expect(tagKeys).toContain('RefName');
expect(tagKeys).not.toContain('RunId');
});
it('rejects custom-tags that would exceed the session-tag limit on their own', {}, async () => {
// 7 protected + 44 custom = 51, which is over 50 even with zero overrideable tags.
const customTagsObj: Record<string, string> = {};
for (let i = 0; i < 44; i++) {
customTagsObj[`Custom${i}`] = `value${i}`;
}
vi.mocked(core.getInput).mockImplementation(
mocks.getInput({
...mocks.IAM_ASSUMEROLE_INPUTS,
'custom-tags': JSON.stringify(customTagsObj),
}),
);
await run();
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('would exceed the AWS limit of 50'));
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
});
it('drops transitive-tag-keys entries that refer to evicted overrideable tags', {}, async () => {
// Force eviction of all overrideable tags below EventName/BaseRef/HeadRef. The user transitive-tags
// RunId (which gets evicted) and Repository (which is protected and stays). The TransitiveTagKeys
// payload must include only the keys that actually appear in Tags.
const customTagsObj: Record<string, string> = {};
for (let i = 0; i < 40; i++) {
customTagsObj[`Custom${i}`] = `value${i}`;
}
vi.mocked(core.getInput).mockImplementation(
mocks.getInput({
...mocks.IAM_ASSUMEROLE_INPUTS,
'custom-tags': JSON.stringify(customTagsObj),
}),
);
vi.mocked(core.getMultilineInput).mockImplementation((name: string) => {
if (name === 'transitive-tag-keys') return ['Repository', 'RunId'];
return [];
});
await run();
const callInput = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input;
const tagKeys = (callInput.Tags ?? []).map((t) => t.Key);
expect(tagKeys).not.toContain('RunId');
expect(callInput.TransitiveTagKeys).toEqual(['Repository']);
});
});
describe('Odd inputs', {}, () => {
@ -1283,6 +1482,8 @@ describe('Configure AWS Credentials', {}, () => {
it('omits tokens when env vars are unset, with no warning', async () => {
vi.resetModules();
delete process.env.GITHUB_ACTION;
delete process.env.GITHUB_RUN_ID;
delete process.env.GITHUB_RUN_ATTEMPT;
const ua = await getCustomUserAgent();
expect(ua).toEqual([['configure-aws-credentials-for-github-actions']]);
expect(core.warning).not.toHaveBeenCalled();
@ -1314,6 +1515,8 @@ describe('Configure AWS Credentials', {}, () => {
it('rejects GITHUB_ACTION containing whitespace or other characters', async () => {
vi.resetModules();
process.env.GITHUB_ACTION = 'has space';
delete process.env.GITHUB_RUN_ID;
delete process.env.GITHUB_RUN_ATTEMPT;
const ua = await getCustomUserAgent();
expect(ua).toEqual([['configure-aws-credentials-for-github-actions']]);
expect(core.warning).toHaveBeenCalledWith('GITHUB_ACTION has unexpected format; omitting from User-Agent');

View file

@ -93,6 +93,15 @@ const envs = {
GITHUB_SHA: 'MY-COMMIT-ID',
GITHUB_WORKSPACE: '/home/github',
GITHUB_ACTIONS: 'true',
GITHUB_REF: 'refs/pull/42/merge',
GITHUB_EVENT_NAME: 'pull_request',
GITHUB_RUN_ID: '16412345678',
GITHUB_JOB: 'build',
GITHUB_REF_NAME: 'feature-branch',
GITHUB_REF_TYPE: 'branch',
GITHUB_BASE_REF: 'main',
GITHUB_HEAD_REF: 'feature-branch',
GITHUB_TRIGGERING_ACTOR: 'MY-USERNAME[bot]',
};
const outputs = {