diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 49962b7..78a8c18 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -* @sonarsource/platform-team +@sonarsource/platform-eng-xp-squad diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml new file mode 100644 index 0000000..39c8e46 --- /dev/null +++ b/.github/workflows/test-action.yml @@ -0,0 +1,63 @@ +name: Test + +on: + push: + branches: [ master ] + pull_request: + +jobs: + build: + runs-on: sonar-runner-large + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: jdx/mise-action@5cb1df66ed5e1fb3c670ea0b62fd17a76979826a # v2.3.1 + - name: Cache Python dependencies + uses: ./ + with: + path: | + ~/.cache/pip + key: python-${{ runner.os }}-pytest-requests + restore-keys: python-${{ runner.os }}- + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest requests + - name: Run tests + run: python -m pytest --version + + cache-with-fallback: + runs-on: sonar-runner-large + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: jdx/mise-action@5cb1df66ed5e1fb3c670ea0b62fd17a76979826a # v2.3.1 + - name: Cache Go modules with multiple restore keys + uses: ./ + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + go-${{ runner.os }}-${{ hashFiles('**/go.mod') }} + go-${{ runner.os }}- + fail-on-cache-miss: false + - name: Create simple Go module + run: | + go mod init example + echo 'package main + import "fmt" + func main() { + fmt.Println("Hello, World!") + }' > main.go + - name: Download dependencies + run: go mod download + - name: Build + run: go build -o hello main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5eec986 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..c7168b1 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +python 3.13.5 +go 1.21.13 diff --git a/README.md b/README.md index 4957f81..65dab0f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,50 @@ -# gh-action-cache +# S3 Cache Action -GitHub action for caching in AWS S3 +A GitHub Action that provides branch-specific caching on AWS S3 with intelligent fallback to default branch cache entries. + +## Features + +- **Branch-specific caching**: Cache entries are prefixed with `GITHUB_HEAD_REF` for granular permissions +- **Intelligent fallback**: Feature branches can fall back to default branch cache when no branch-specific cache exists +- **S3 storage**: Leverages AWS S3 for reliable, scalable cache storage +- **AWS Cognito authentication**: Secure authentication using GitHub Actions OIDC tokens +- **Compatible with actions/cache**: Drop-in replacement with same interface + +## Usage + +```yaml +- uses: SonarSource/gh-action_cache@v1 + with: + path: | + ~/.npm + ~/.cache + key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + node-${{ runner.os }} + s3-bucket: your-cache-bucket +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `path` | Files, directories, and wildcard patterns to cache | Yes | | +| `key` | Explicit key for restoring and saving cache | Yes | | +| `restore-keys` | Ordered list of prefix-matched keys for fallback | No | | +| `s3-bucket` | S3 bucket name for cache storage | No | `sonarsource-s3-cache-dev-bucket` | +| `upload-chunk-size` | Chunk size for large file uploads (bytes) | No | | +| `enableCrossOsArchive` | Enable cross-OS cache compatibility | No | `false` | +| `fail-on-cache-miss` | Fail workflow if cache entry not found | No | `false` | +| `lookup-only` | Only check cache existence without downloading | No | `false` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `cache-hit` | Boolean indicating exact match for primary key | + +## Security + +- Uses GitHub Actions OIDC tokens for secure authentication +- No long-lived AWS credentials required +- Branch-specific paths provide isolation between branches diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..da56450 --- /dev/null +++ b/action.yml @@ -0,0 +1,140 @@ +name: 'S3 Cache action' +description: 'Cache files on S3 with branch-specific paths for granular permissions' +author: 'SonarSource' + +inputs: + path: + description: 'A list of files, directories, and wildcard patterns to cache and restore' + required: true + key: + description: 'An explicit key for restoring and saving the cache' + required: true + restore-keys: + description: 'An ordered list of prefix-matched keys to use for restoring stale cache if no cache hit occurred for key' + upload-chunk-size: + description: 'The chunk size used to split up large files during upload, in bytes' + enableCrossOsArchive: + description: 'An optional boolean when enabled, allows windows runners to save or restore caches that can be restored or saved respectively on other platforms' + default: 'false' + fail-on-cache-miss: + description: 'Fail the workflow if cache entry is not found' + default: 'false' + lookup-only: + description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache' + default: 'false' + s3-bucket: + description: 'S3 bucket name for cache storage' + default: 'sonarsource-s3-cache-dev-bucket' + +outputs: + cache-hit: + description: 'A boolean value to indicate an exact match was found for the primary key' + value: ${{ steps.cache.outputs.cache-hit }} + +runs: + using: 'composite' + steps: + - name: Authenticate to AWS + shell: bash + env: # TODO: Another set of variables needed for production, support GH cache BUILD-8451 + POOL_ID: eu-central-1:2f2d946d-08df-415c-9b0c-d097bef49dcc + AWS_ACCOUNT_ID: 460386131003 + IDENTITY_PROVIDER_NAME: token.actions.githubusercontent.com + AUDIENCE: cognito-identity.amazonaws.com + AWS_REGION: eu-central-1 + run: | + # Get GitHub Actions ID token + ACCESS_TOKEN=$(curl -sLS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r ".value") + echo "::add-mask::$ACCESS_TOKEN" + + # Get Identity ID + identityId=$(aws cognito-identity get-id \ + --identity-pool-id "$POOL_ID" \ + --account-id "$AWS_ACCOUNT_ID" \ + --logins '{"'"$IDENTITY_PROVIDER_NAME"'":"'"$ACCESS_TOKEN"'"}' \ + --query 'IdentityId' --output text) + + # Get and validate AWS credentials + awsCredentials=$(aws cognito-identity get-credentials-for-identity \ + --identity-id "$identityId" \ + --logins '{"'"$IDENTITY_PROVIDER_NAME"'":"'"$ACCESS_TOKEN"'"}') + + AWS_ACCESS_KEY_ID=$(echo "$awsCredentials" | jq -r ".Credentials.AccessKeyId") + AWS_SECRET_ACCESS_KEY=$(echo "$awsCredentials" | jq -r ".Credentials.SecretKey") + AWS_SESSION_TOKEN=$(echo "$awsCredentials" | jq -r ".Credentials.SessionToken") + + echo "::add-mask::$AWS_ACCESS_KEY_ID" + echo "::add-mask::$AWS_SECRET_ACCESS_KEY" + echo "::add-mask::$AWS_SESSION_TOKEN" + + if [[ "$AWS_ACCESS_KEY_ID" == "null" || -z "$AWS_ACCESS_KEY_ID" ]]; then + echo "::error::Failed to obtain AWS Access Key ID" + exit 1 + fi + + if [[ "$AWS_SECRET_ACCESS_KEY" == "null" || -z "$AWS_SECRET_ACCESS_KEY" ]]; then + echo "::error::Failed to obtain AWS Secret Access Key" + exit 1 + fi + + if [[ "$AWS_SESSION_TOKEN" == "null" || -z "$AWS_SESSION_TOKEN" ]]; then + echo "::error::Failed to obtain AWS Session Token" + exit 1 + fi + + echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> $GITHUB_ENV + echo "AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN" >> $GITHUB_ENV + + - name: Prepare cache keys + shell: bash + id: prepare-keys + run: | + # Prepend GITHUB_HEAD_REF to the main cache key + BRANCH_KEY="${GITHUB_HEAD_REF}/${{ inputs.key }}" + echo "branch-key=${BRANCH_KEY}" >> $GITHUB_OUTPUT + + # Process restore keys: keep branch-specific keys and add fallback to default branch + if [ -n "${{ inputs.restore-keys }}" ]; then + RESTORE_KEYS="" + # First, add branch-specific restore keys + while IFS= read -r line; do + if [ -n "$line" ]; then + if [ -n "$RESTORE_KEYS" ]; then + RESTORE_KEYS="${RESTORE_KEYS}"$'\n'"${GITHUB_HEAD_REF}/${line}" + else + RESTORE_KEYS="${GITHUB_HEAD_REF}/${line}" + fi + fi + done <<< "${{ inputs.restore-keys }}" + + # Then, add default branch fallback keys (without GITHUB_HEAD_REF prefix) + while IFS= read -r line; do + if [ -n "$line" ]; then + RESTORE_KEYS="${RESTORE_KEYS}"$'\n'"${line}" + fi + done <<< "${{ inputs.restore-keys }}" + + echo "branch-restore-keys<> $GITHUB_OUTPUT + echo "$RESTORE_KEYS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + - name: Cache with runs-on/cache + uses: runs-on/cache@3a15256b3556fbc5ae15f7f04598e4c7680e9c25 # v4.0.0 + id: cache + env: + RUNS_ON_S3_BUCKET_CACHE: ${{ inputs.s3-bucket }} + AWS_DEFAULT_REGION: eu-central-1 + with: + path: ${{ inputs.path }} + key: ${{ steps.prepare-keys.outputs.branch-key }} + restore-keys: ${{ steps.prepare-keys.outputs.branch-restore-keys }} + upload-chunk-size: ${{ inputs.upload-chunk-size }} + enableCrossOsArchive: ${{ inputs.enableCrossOsArchive }} + fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }} + lookup-only: ${{ inputs.lookup-only }} + +branding: + icon: 'upload-cloud' + color: 'blue'