Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .github/actions/create-aws-role-session-name/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Create AWS Role Session Name Changelog

All notable changes to the `create-aws-role-session-name` composite action are documented in this file.

## 1.0.0

### Added

- Build role session names from the operation, environment, run ID, and actor.
- Replace AWS-incompatible bytes and truncate output to the 64-character limit.
- Expose the sanitized role session name as the `name` output.
64 changes: 64 additions & 0 deletions .github/actions/create-aws-role-session-name/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Create AWS Role Session Name

## Summary

Builds a deterministic, AWS-compatible role session name from workflow context. This action only constructs and sanitizes the name; it does not configure AWS credentials.

The unsanitized name has this form:

```text
<action>-<environment>-Run<run-id>-@<actor>
```

## Inputs

| Name | Required | Description |
| ------------- | -------- | ------------------------------------------- |
| `action` | Yes | Operation being performed, such as `build`. |
| `environment` | Yes | Target environment. |
| `run-id` | Yes | GitHub Actions run ID. |
| `actor` | Yes | GitHub actor that triggered the workflow. |

## Outputs

| Name | Description |
| ------ | --------------------------------- |
| `name` | AWS-compatible role session name. |

## Sanitization contract

- The allowed characters are `A-Z`, `a-z`, `0-9`, `_`, `+`, `=`, `,`, `.`, `@`, and `-`.
- Each invalid byte is replaced with `-`. For example, `renovate[bot]` becomes `renovate-bot-` within the complete session name.
- The result is truncated to at most 64 characters.
- The action fails if the result contains fewer than two characters.
- Character processing uses the C locale for deterministic byte handling.
- Inputs are passed to the shell through environment variables and are never interpolated into shell source.

The output always matches `^[A-Za-z0-9_+=,.@-]{2,64}$`.

## Usage

After version `1.0.0` is released, pin usage to the immutable commit SHA associated with `actions/create-aws-role-session-name/1.0.0`:

```yaml
- name: Build AWS role session name
id: aws-session
# Replace with the immutable commit SHA for actions/create-aws-role-session-name/1.0.0.
uses: OpenSesame/core-github-actions/.github/actions/create-aws-role-session-name@<immutable-commit-sha>
with:
action: build
environment: ${{ inputs.environment }}
run-id: ${{ github.run_id }}
actor: ${{ github.actor }}

- name: Use role session name
env:
ROLE_SESSION_NAME: ${{ steps.aws-session.outputs.name }}
run: printf '%s\n' "$ROLE_SESSION_NAME"
```

This action does not require additional GitHub token permissions.

## Versioning

This action follows the repository's component versioning policy. The initial release uses the PR label `version:actions/create-aws-role-session-name/1.0.0` and the namespaced tag `actions/create-aws-role-session-name/1.0.0`.
154 changes: 154 additions & 0 deletions .github/actions/create-aws-role-session-name/action.unit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');

const actionPath = path.join(__dirname, 'action.yml');

function extractRunScript() {
const actionDefinition = fs.readFileSync(actionPath, 'utf8');
const runBlock = actionDefinition.match(/ run: \|\n([\s\S]+)$/);

if (!runBlock) {
throw new Error('Unable to find the action run script');
}

return runBlock[1]
.split('\n')
.map(line => line.replace(/^ {8}/, ''))
.join('\n');
}

function readOutput(outputPath) {
const outputLine = fs.readFileSync(outputPath, 'utf8').trim();

if (!outputLine.startsWith('name=')) {
throw new Error('Action output must use the expected name=<value> format');
}

return outputLine.slice('name='.length);
}

function runAction(inputs) {
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitize-aws-session-'));
const outputPath = path.join(temporaryDirectory, 'github-output');

try {
const result = spawnSync('bash', ['-c', extractRunScript()], {
cwd: temporaryDirectory,
encoding: 'utf8',
env: {
...process.env,
GITHUB_OUTPUT: outputPath,
SESSION_ACTION: inputs.action,
SESSION_ENVIRONMENT: inputs.environment,
SESSION_RUN_ID: inputs.runId,
SESSION_ACTOR: inputs.actor,
},
});

return {
...result,
output: result.status === 0 ? readOutput(outputPath) : undefined,
shellMarkerCreated: fs.existsSync(
path.join(temporaryDirectory, 'shell-metacharacter-was-executed')
),
};
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
}

describe('create-aws-role-session-name', () => {
test('rejects malformed GitHub output lines', () => {
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'malformed-github-output-'));
const outputPath = path.join(temporaryDirectory, 'github-output');
fs.writeFileSync(outputPath, 'value-without-an-output-name\n');

try {
expect(() => readOutput(outputPath)).toThrow(
'Action output must use the expected name=<value> format'
);
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
});

test('replaces brackets in bot actor names', () => {
const result = runAction({
action: 'build',
environment: 'dev',
runId: '12345',
actor: 'renovate[bot]',
});

expect(result.status).toBe(0);
expect(result.output).toBe('build-dev-Run12345-@renovate-bot-');
});

test('preserves all allowed characters', () => {
const result = runAction({
action: 'build_+=,.@-',
environment: 'stage_+=,.@-',
runId: '123',
actor: 'actor_+=,.@-',
});

expect(result.status).toBe(0);
expect(result.output).toBe('build_+=,.@--stage_+=,.@--Run123-@actor_+=,.@-');
});

test('replaces invalid characters without executing shell metacharacters', () => {
const markerName = 'shell-metacharacter-was-executed';
const result = runAction({
action: 'build; touch',
environment: 'feature/name with spaces',
runId: '123$(false)',
actor: `actor[bot] && touch ${markerName}`,
});

expect(result.status).toBe(0);
expect(result.output).toBe(
`build--touch-feature-name-with-spaces-Run123--false--@actor-bot-----touch-${markerName}`.slice(
0,
64
)
);
expect(result.shellMarkerCreated).toBe(false);
});

test('truncates long values to exactly 64 characters', () => {
const result = runAction({
action: 'deploy',
environment: 'production',
runId: '12345',
actor: 'a'.repeat(100),
});

expect(result.status).toBe(0);
expect(result.output).toHaveLength(64);
});

test('replaces each invalid multibyte input byte deterministically', () => {
const result = runAction({
action: 'build',
environment: 'dev',
runId: '12345',
actor: 'renovateé',
});

expect(result.status).toBe(0);
expect(result.output).toBe('build-dev-Run12345-@renovate--');
});

test.each([
['build', 'dev', '12345', 'octocat'],
['plan', 'stage', '67890', 'renovate[bot]'],
['apply', 'prod/us east', '24680', 'actor;$(false)'],
])('always produces a valid AWS role session name', (action, environment, runId, actor) => {
const result = runAction({ action, environment, runId, actor });

expect(result.status).toBe(0);
expect(result.output).toMatch(/^[A-Za-z0-9_+=,.@-]{2,64}$/);
});
});
50 changes: 50 additions & 0 deletions .github/actions/create-aws-role-session-name/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Create AWS role session name
description: Builds an AWS-compatible role session name and sanitizes invalid bytes

inputs:
action:
description: Operation being performed, such as build, plan, apply, or deploy
required: true
environment:
description: Target environment
required: true
run-id:
description: GitHub Actions run ID
required: true
actor:
description: GitHub actor that triggered the workflow
required: true

outputs:
name:
description: AWS-compatible role session name
value: ${{ steps.sanitize.outputs.name }}

runs:
using: composite
steps:
- name: Build and sanitize role session name
id: sanitize
shell: bash
env:
SESSION_ACTION: ${{ inputs.action }}
SESSION_ENVIRONMENT: ${{ inputs.environment }}
SESSION_RUN_ID: ${{ inputs.run-id }}
SESSION_ACTOR: ${{ inputs.actor }}
run: |
set -Eeuo pipefail

role_session_name="${SESSION_ACTION}-${SESSION_ENVIRONMENT}-Run${SESSION_RUN_ID}-@${SESSION_ACTOR}"
sanitized_name="$(
export LC_ALL=C
printf '%s' "$role_session_name" \
| tr -c 'A-Za-z0-9_+=,.@-' '-' \
| cut -c 1-64
)"

if ((${#sanitized_name} < 2)); then
echo "AWS role session names must contain at least two characters" >&2
exit 1
fi

printf 'name=%s\n' "$sanitized_name" >> "$GITHUB_OUTPUT"
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

The Core Services team uses GitHub Actions to standardize our CI/CD process.

## ❌ Deprecation Notice: Composite Actions
## ❌ Deprecation Notice: Legacy Composite Actions

An earlier version of the 'Core' team wrote composite actions in this repository to provide a standardized way for teams to build, test, and deploy software.
An earlier version of the 'Core' team wrote root-level composite actions in this repository to provide a standardized way for teams to build, test, and deploy software.
These actions have not been actively maintained in years and are considered deprecated by the current Core Services team.

- [build](./build)
Expand All @@ -25,7 +25,7 @@ These actions have not been actively maintained in years and are considered depr

### What we're doing instead

The Core Services team is writing a standard set of reusable workflows defined in this same repository for use by our repos. This approach improves visibility, reduces hidden complexity, and ensures pipelines follow current standards.
The Core Services team uses reusable workflows for shared CI/CD orchestration so standard pipeline phases remain visible. Narrowly scoped utility composite actions are also acceptable when they live under `.github/actions`, have a cohesive contract, and follow this repository's component versioning policy.

### Maintenance ownership of the old composite actions

Expand All @@ -36,7 +36,8 @@ The Core Services team is writing a standard set of reusable workflows defined i
### Migration options

- Copy the composite action code directly into your workflow in place of calling the composite action.
- Consider writing your own reusable GHA or discuss with us ways to make ours more widely adoptable and maintainable.
- Use a reusable workflow for multi-phase CI/CD orchestration.
- Use a versioned utility composite action under `.github/actions` when the behavior is cohesive and does not hide standard pipeline phases.

## ⚠️ Versioning Warning

Expand Down Expand Up @@ -80,7 +81,7 @@ A complete policy is defined in [VERSIONING.md](VERSIONING.md). Highlights:

### 🚧 Reusable Workflows (Work in Progress)

The Core Services team is moving away from composite actions and building **reusable workflows** in this repository.
The Core Services team is moving legacy multi-phase orchestration out of composite actions and into **reusable workflows** in this repository.

At this stage, the reusable workflows support **Terraform-only projects**. They are still evolving and are not yet versioned. While they can be consumed by other repositories, their API is not considered stable. Their contracts remain subject to change until the versioning model expands to reusable workflows. These workflows should be referenced by the `legacy-stable` tag. This allows us to make changes to bring the workflows under versioning safely.

Expand Down
Loading
Loading