Skip to content
Open
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
115 changes: 115 additions & 0 deletions .github/actions/aws.codeartifact.configure-credentials/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# AWS - CodeArtifact Configure Credentials

Fetch a CodeArtifact authorization token and a repository endpoint using the job's
existing AWS credentials. Supports Python (`pypi`), Node.js (`npm`), and JVM
(`maven`) package formats without choosing a package manager or modifying its
configuration files.

## Requirements

- Bash and AWS CLI v2 on the runner. No action-specific runtime dependencies or
architecture-specific binaries are installed; Linux amd64 and arm64 are tested.
- AWS credentials configured before this action, for example with
[`aws.configure-credentials`](../aws.configure-credentials/action.yaml).
- `codeartifact:GetAuthorizationToken` on the domain,
`codeartifact:GetRepositoryEndpoint` on the repository, and
`sts:GetServiceBearerToken` with the `sts:AWSServiceName` condition set to
`codeartifact.amazonaws.com`.

Downloading packages additionally requires `codeartifact:ReadFromRepository`.
Publishing requires the corresponding package write permissions, such as
`codeartifact:PublishPackageVersion` and `codeartifact:PutPackageMetadata`.
Cross-account access also requires the appropriate domain/repository policies.
The action does not grant permissions or create repositories.

## Usage

Install AWS CLI v2 first, for example through `mise.setup-tools` and the caller's
`mise.toml`. The following steps assume it is available on `PATH`.

```yaml
permissions:
contents: read
id-token: write

jobs:
package:
runs-on: ubuntu-24.04
steps:
- name: Configure AWS Credentials
uses: tedilabs/github-actions/.github/actions/aws.configure-credentials@main
with:
aws_region: ap-northeast-1
aws_github_oidc_iam_role: ${{ secrets.AWS_GITHUB_OIDC_IAM_ROLE }}
# Optional: assume a separate CodeArtifact reader or publisher role.
aws_chained_iam_role: ${{ secrets.CODEARTIFACT_IAM_ROLE }}

- name: Configure CodeArtifact Credentials
id: codeartifact
uses: tedilabs/github-actions/.github/actions/aws.codeartifact.configure-credentials@main
with:
aws_region: ap-northeast-1
domain: example
domain_owner: "111122223333"
repository: internal
format: pypi
```

The `domain_owner` is the account owning the domain, which may differ from the
account of the assumed role. `format` selects an endpoint, not an access level.

## Inputs and outputs

| Input | Required | Default | Description |
| --- | --- | --- | --- |
| `aws_region` | Yes | | AWS region of the domain. |
| `domain` | Yes | | CodeArtifact domain name. |
| `domain_owner` | Yes | | AWS account ID owning the domain. |
| `repository` | Yes | | CodeArtifact repository name. |
| `format` | Yes | | `pypi`, `npm`, or `maven`. |
| `token_duration_seconds` | No | `0` | `0` or an integer from `900` through `43200`. |

The default token duration of `0` uses the remaining lifetime of the assumed-role
session. When using IAM user credentials, specify a duration from `900` through
`43200` instead. Long-running jobs must renew AWS credentials and rerun this action
before the credentials expire. See [AWS token authentication](https://docs.aws.amazon.com/codeartifact/latest/ug/tokens-authentication.html).

After both AWS API calls succeed, subsequent steps in the same job receive:

- `CODEARTIFACT_AUTH_TOKEN`: masked authorization token.
- `CODEARTIFACT_REPOSITORY_ENDPOINT`: endpoint returned by AWS for `format`.

The endpoint is also available as `steps.<id>.outputs.repository_endpoint`.
There is intentionally no token output. Authenticate separately in each job;
do not put the token in artifacts, caches, or job outputs. Calling this action
again replaces both environment variables for subsequent steps. An unsuccessful
call exports nothing new and fails the step; do not continue after that failure.

## Package manager configuration

The caller configures its package manager with these environment variables:

| Client | How to use the credentials |
| --- | --- |
| uv | Configure a named index, with username `aws` and the token as its password. The index URL is the PyPI endpoint plus `simple/`; the publish URL is the endpoint itself. |
| pip / Twine | pip uses the PyPI endpoint plus `simple/`. Twine uses the endpoint itself with username `aws` and the token as its password. |
| npm / pnpm | Configure the npm endpoint as the registry and reference the token in registry-scoped authentication settings. |
| Yarn | Configure the npm registry and authentication in the project's Yarn configuration. |
| Maven / Gradle | Configure the Maven endpoint with username `aws` and the token as its password. |

The action preserves the endpoint returned by AWS, including its trailing slash.
It does not append `/simple/` or embed credentials in URLs. Package manager
configuration and cleanup remain under the caller's control.

## Tests

Run the regression tests with uv (PyYAML is a test-only dependency):

```sh
uv run .github/actions/aws.codeartifact.configure-credentials/tests/test_action.py
```

The tests execute the actual inline shell step with a mocked AWS CLI. CI also
invokes the composite action and verifies its outputs and subsequent-step
environment on Linux amd64 and arm64. These tests need no AWS credentials and do
not prove access to a live CodeArtifact repository.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: AWS - CodeArtifact Configure Credentials
description: Export a CodeArtifact authorization token and repository endpoint using the job's AWS credentials.


inputs:
aws_region:
required: true
description: "(Required) The AWS region of the CodeArtifact domain."
domain:
required: true
description: "(Required) The name of the CodeArtifact domain."
domain_owner:
required: true
description: "(Required) The AWS account ID that owns the CodeArtifact domain."
repository:
required: true
description: "(Required) The name of the CodeArtifact repository."
format:
required: true
description: "(Required) The package format: `pypi`, `npm`, or `maven`."
token_duration_seconds:
required: false
default: "0"
description: "(Optional) The token lifetime in seconds: `0` or `900` through `43200`. Defaults to `0`, which requires assumed-role credentials and uses the remaining role session lifetime."

outputs:
repository_endpoint:
value: ${{ steps.configure-credentials.outputs.repository_endpoint }}
description: "The repository endpoint for the requested package format, without credentials."


runs:
using: composite

steps:
- name: Configure CodeArtifact Credentials
id: configure-credentials
shell: bash
env:
INPUT_AWS_REGION: ${{ inputs.aws_region }}
INPUT_DOMAIN: ${{ inputs.domain }}
INPUT_DOMAIN_OWNER: ${{ inputs.domain_owner }}
INPUT_REPOSITORY: ${{ inputs.repository }}
INPUT_FORMAT: ${{ inputs.format }}
INPUT_TOKEN_DURATION_SECONDS: ${{ inputs.token_duration_seconds }}
AWS_CLI_AUTO_PROMPT: "off"
run: |
set -euo pipefail

for input in AWS_REGION DOMAIN DOMAIN_OWNER REPOSITORY FORMAT TOKEN_DURATION_SECONDS; do
variable="INPUT_$input"
if [[ -z "${!variable}" ]]; then
echo "::error::Missing required input: $input."
exit 1
fi
done

case "$INPUT_FORMAT" in
pypi|npm|maven) ;;
*) echo "::error::format must be pypi, npm, or maven."; exit 1 ;;
esac

duration="$INPUT_TOKEN_DURATION_SECONDS"
if ! [[ "$duration" =~ ^(0|[1-9][0-9]{0,4})$ ]]; then
echo "::error::token_duration_seconds must be 0 or an integer from 900 through 43200."
exit 1
fi
if (( duration != 0 && (duration < 900 || duration > 43200) )); then
echo "::error::token_duration_seconds must be 0 or an integer from 900 through 43200."
exit 1
fi

aws_args=(--region "$INPUT_AWS_REGION" --domain "$INPUT_DOMAIN" --domain-owner "$INPUT_DOMAIN_OWNER" --output text --no-cli-pager)
token="$(aws codeartifact get-authorization-token "${aws_args[@]}" \
--duration-seconds "$duration" --query authorizationToken)"

if [[ -z "$token" || "$token" == None || "$token" == null || "$token" == *$'\n'* || "$token" == *$'\r'* ]]; then
echo "::error::AWS did not return a valid authorization token."
exit 1
fi
echo "::add-mask::$token"

endpoint="$(aws codeartifact get-repository-endpoint "${aws_args[@]}" \
--repository "$INPUT_REPOSITORY" --format "$INPUT_FORMAT" --query repositoryEndpoint)"

if [[ "$endpoint" != https://* || "$endpoint" == *$'\n'* || "$endpoint" == *$'\r'* ]]; then
echo "::error::AWS did not return a valid repository endpoint."
exit 1
fi

# Export only after both API calls succeed. The token is never an action output.
{
echo "CODEARTIFACT_AUTH_TOKEN=$token"
echo "CODEARTIFACT_REPOSITORY_ENDPOINT=$endpoint"
} >> "$GITHUB_ENV"
echo "repository_endpoint=$endpoint" >> "$GITHUB_OUTPUT"
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Offline AWS CLI fixture shared by regression and composite smoke tests."""

import json
import os
import sys

arguments = sys.argv[1:]
with open(os.environ["MOCK_AWS_CALLS"], "a") as calls:
calls.write(json.dumps(arguments) + "\n")

operation = arguments[1]
if operation == os.environ.get("MOCK_AWS_FAILURE"):
print("Mock AWS API failure", file=sys.stderr)
sys.exit(1)

if operation == "get-authorization-token":
print(os.environ.get("MOCK_AWS_TOKEN", "mock-codeartifact-token"))
elif operation == "get-repository-endpoint":
package_format = arguments[arguments.index("--format") + 1]
endpoint = f"https://example-111122223333.d.codeartifact.ap-northeast-1.amazonaws.com/{package_format}/internal/"
print(os.environ.get("MOCK_AWS_ENDPOINT", endpoint))
else:
sys.exit("Unexpected AWS operation")
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["PyYAML==6.0.3"]
# ///
"""Run the actual composite shell step with an offline AWS CLI."""

import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest

import yaml


ACTION_DIR = Path(__file__).resolve().parents[1]
ACTION = yaml.safe_load((ACTION_DIR / "action.yaml").read_text())
STEP = ACTION["runs"]["steps"][0]


class ConfigureCredentialsTest(unittest.TestCase):
def run_action(self, inputs=None, **mock):
values = {
"aws_region": "ap-northeast-1",
"domain": "example",
"domain_owner": "111122223333",
"repository": "internal",
"format": "pypi",
"token_duration_seconds": ACTION["inputs"]["token_duration_seconds"]["default"],
**(inputs or {}),
}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
aws = root / "aws"
shutil.copy(ACTION_DIR / "tests/mock-aws.py", aws)
aws.chmod(0o755)
environment = {**os.environ, **mock, "PATH": f"{root}:{os.environ['PATH']}"}
for name, expression in STEP["env"].items():
if expression.startswith("${{ inputs."):
environment[name] = values[expression.removeprefix("${{ inputs.").removesuffix(" }}")]
else:
environment[name] = expression
files = {name: root / name for name in ("GITHUB_ENV", "GITHUB_OUTPUT", "MOCK_AWS_CALLS")}
for name, path in files.items():
path.touch()
environment[name] = str(path)
result = subprocess.run(
["bash", "-c", STEP["run"]], env=environment, text=True, capture_output=True
)
contents = {name: path.read_text() for name, path in files.items()}
return result, contents

def test_formats_export_credentials_and_forward_arguments(self):
for package_format in ("pypi", "npm", "maven"):
for duration in ("0", "900", "43200"):
with self.subTest(format=package_format, duration=duration):
result, files = self.run_action({"format": package_format, "token_duration_seconds": duration})
self.assertEqual(result.returncode, 0, result.stderr)
endpoint = f"https://example-111122223333.d.codeartifact.ap-northeast-1.amazonaws.com/{package_format}/internal/"
self.assertEqual(files["GITHUB_ENV"], f"CODEARTIFACT_AUTH_TOKEN=mock-codeartifact-token\nCODEARTIFACT_REPOSITORY_ENDPOINT={endpoint}\n")
self.assertEqual(files["GITHUB_OUTPUT"], f"repository_endpoint={endpoint}\n")
self.assertEqual(result.stdout, "::add-mask::mock-codeartifact-token\n")
self.assertEqual(result.stderr, "")
calls = [json.loads(line) for line in files["MOCK_AWS_CALLS"].splitlines()]
common = ["--region", "ap-northeast-1", "--domain", "example", "--domain-owner", "111122223333", "--output", "text", "--no-cli-pager"]
self.assertEqual(calls, [
["codeartifact", "get-authorization-token", *common, "--duration-seconds", duration, "--query", "authorizationToken"],
["codeartifact", "get-repository-endpoint", *common, "--repository", "internal", "--format", package_format, "--query", "repositoryEndpoint"],
])

def test_invalid_inputs_fail_before_aws_calls(self):
cases = [{key: ""} for key in ACTION["inputs"]]
cases += [{"format": "python"}]
cases += [{"token_duration_seconds": value} for value in ("-1", "899", "43201", "0900", "1.5", "$(exit 0)", "999999999999999999999")]
for inputs in cases:
with self.subTest(inputs=inputs):
result, files = self.run_action(inputs)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(all(value == "" for value in files.values()))

def test_api_failures_export_nothing(self):
for operation in ("get-authorization-token", "get-repository-endpoint"):
with self.subTest(operation=operation):
result, files = self.run_action(MOCK_AWS_FAILURE=operation)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(files["GITHUB_ENV"], "")
self.assertEqual(files["GITHUB_OUTPUT"], "")
self.assertIn("Mock AWS API failure", result.stderr)
if operation == "get-repository-endpoint":
self.assertEqual(result.stdout, "::add-mask::mock-codeartifact-token\n")

def test_invalid_responses_cannot_inject_environment_variables(self):
for key in ("MOCK_AWS_TOKEN", "MOCK_AWS_ENDPOINT"):
for value in ("", "None", "null", "https://example/\nINJECTED=value", "https://example/\rINJECTED=value"):
with self.subTest(key=key, value=value):
result, files = self.run_action(**{key: value})
self.assertNotEqual(result.returncode, 0)
self.assertEqual(files["GITHUB_ENV"], "")
self.assertEqual(files["GITHUB_OUTPUT"], "")
self.assertNotIn("INJECTED", result.stdout + result.stderr)


if __name__ == "__main__":
unittest.main()
Loading
Loading