From 2fd8e23afe4502c06b917a5f9c1b25680b61dcdf Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sun, 25 Oct 2026 21:28:04 +0900 Subject: [PATCH] feat(aws.codeartifact.configure-credentials): add package-manager-independent authentication --- .../README.md | 115 ++++++++++++++++++ .../action.yaml | 96 +++++++++++++++ .../tests/mock-aws.py | 24 ++++ .../tests/test_action.py | 106 ++++++++++++++++ ...deartifact.configure-credentials.test.yaml | 74 +++++++++++ 5 files changed, 415 insertions(+) create mode 100644 .github/actions/aws.codeartifact.configure-credentials/README.md create mode 100644 .github/actions/aws.codeartifact.configure-credentials/action.yaml create mode 100644 .github/actions/aws.codeartifact.configure-credentials/tests/mock-aws.py create mode 100644 .github/actions/aws.codeartifact.configure-credentials/tests/test_action.py create mode 100644 .github/workflows/aws.codeartifact.configure-credentials.test.yaml diff --git a/.github/actions/aws.codeartifact.configure-credentials/README.md b/.github/actions/aws.codeartifact.configure-credentials/README.md new file mode 100644 index 0000000..d5a0cb2 --- /dev/null +++ b/.github/actions/aws.codeartifact.configure-credentials/README.md @@ -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..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. diff --git a/.github/actions/aws.codeartifact.configure-credentials/action.yaml b/.github/actions/aws.codeartifact.configure-credentials/action.yaml new file mode 100644 index 0000000..2fdc398 --- /dev/null +++ b/.github/actions/aws.codeartifact.configure-credentials/action.yaml @@ -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" diff --git a/.github/actions/aws.codeartifact.configure-credentials/tests/mock-aws.py b/.github/actions/aws.codeartifact.configure-credentials/tests/mock-aws.py new file mode 100644 index 0000000..39fa4de --- /dev/null +++ b/.github/actions/aws.codeartifact.configure-credentials/tests/mock-aws.py @@ -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") diff --git a/.github/actions/aws.codeartifact.configure-credentials/tests/test_action.py b/.github/actions/aws.codeartifact.configure-credentials/tests/test_action.py new file mode 100644 index 0000000..1b2b965 --- /dev/null +++ b/.github/actions/aws.codeartifact.configure-credentials/tests/test_action.py @@ -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() diff --git a/.github/workflows/aws.codeartifact.configure-credentials.test.yaml b/.github/workflows/aws.codeartifact.configure-credentials.test.yaml new file mode 100644 index 0000000..9c96e05 --- /dev/null +++ b/.github/workflows/aws.codeartifact.configure-credentials.test.yaml @@ -0,0 +1,74 @@ +name: Test - CodeArtifact Configure Credentials + + +on: + pull_request: + paths: + - .github/actions/aws.codeartifact.configure-credentials/** + - .github/workflows/aws.codeartifact.configure-credentials.test.yaml + push: + branches: + - main + paths: + - .github/actions/aws.codeartifact.configure-credentials/** + - .github/workflows/aws.codeartifact.configure-credentials.test.yaml + workflow_dispatch: {} + + +permissions: + contents: read + +jobs: + test: + name: Test (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-24.04 + - ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Tools + uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main + with: + mise_toml: | + [tools] + uv = "0.13.2" + + - name: Test + run: uv run .github/actions/aws.codeartifact.configure-credentials/tests/test_action.py + + - name: Mock AWS CLI + shell: bash + run: | + mock_bin="$(mktemp -d "$RUNNER_TEMP/codeartifact-test.XXXXXX")" + cp .github/actions/aws.codeartifact.configure-credentials/tests/mock-aws.py "$mock_bin/aws" + chmod +x "$mock_bin/aws" + echo "$mock_bin" >> "$GITHUB_PATH" + echo "MOCK_AWS_CALLS=$mock_bin/calls.jsonl" >> "$GITHUB_ENV" + + - name: Smoke Test Composite Action + id: smoke + uses: ./.github/actions/aws.codeartifact.configure-credentials + with: + aws_region: ap-northeast-1 + domain: example + domain_owner: "111122223333" + repository: internal + format: npm + + - name: Check Smoke Test Outputs + shell: bash + env: + REPOSITORY_ENDPOINT: ${{ steps.smoke.outputs.repository_endpoint }} + run: | + test "$CODEARTIFACT_AUTH_TOKEN" = mock-codeartifact-token + test "$CODEARTIFACT_REPOSITORY_ENDPOINT" = https://example-111122223333.d.codeartifact.ap-northeast-1.amazonaws.com/npm/internal/ + test "$REPOSITORY_ENDPOINT" = "$CODEARTIFACT_REPOSITORY_ENDPOINT"