From 500e0ee5b346833ab02b5209b7c997380cb5d684 Mon Sep 17 00:00:00 2001 From: Alexis Tual Date: Thu, 16 May 2024 00:49:55 +0200 Subject: [PATCH] Add support for short-lived tokens (#224) The setup-gradle action tries to get a short-lived access token given the supplied Develocity access key. This key can be passed either with the `DEVELOCITY_ACCESS_KEY` env var or via the `develocity-access-key` input parameter. If a token can be retrieved, then the `DEVELOCITY_ACCESS_KEY` env var will be set to the token. Otherwise the `DEVELOCITY_ACCESS_KEY` will be set to a blank string, to avoid a leak. --------- Co-authored-by: daz --- .../integ-test-inject-develocity.yml | 90 ++++++++- build | 8 +- dependency-submission/action.yml | 22 +- docs/setup-gradle.md | 103 ++++++---- setup-gradle/action.yml | 18 +- sources/src/configuration.ts | 8 + sources/src/{ => develocity}/build-scan.ts | 15 +- sources/src/develocity/short-lived-token.ts | 191 ++++++++++++++++++ sources/src/setup-gradle.ts | 4 +- sources/test/jest/short-lived-token.test.ts | 137 +++++++++++++ 10 files changed, 535 insertions(+), 61 deletions(-) rename sources/src/{ => develocity}/build-scan.ts (65%) create mode 100644 sources/src/develocity/short-lived-token.ts create mode 100644 sources/test/jest/short-lived-token.test.ts diff --git a/.github/workflows/integ-test-inject-develocity.yml b/.github/workflows/integ-test-inject-develocity.yml index f0da1ea..a41b141 100644 --- a/.github/workflows/integ-test-inject-develocity.yml +++ b/.github/workflows/integ-test-inject-develocity.yml @@ -26,14 +26,19 @@ jobs: DEVELOCITY_URL: https://ge.solutions-team.gradle.com DEVELOCITY_PLUGIN_VERSION: ${{ matrix.plugin-version }} DEVELOCITY_CCUD_PLUGIN_VERSION: '2.0' - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} # required to test against GE plugin 3.16.2 - DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + ${{matrix.accessKeyEnv}}: ${{ secrets.DEVELOCITY_ACCESS_KEY }} strategy: fail-fast: false matrix: gradle: [current, 7.6.2, 6.9.4, 5.6.4] os: ${{fromJSON(inputs.runner-os)}} plugin-version: [3.16.2, 3.17.3] + include: + - plugin-version: 3.16.2 + accessKeyEnv: GRADLE_ENTERPRISE_ACCESS_KEY + - plugin-version: 3.17.3 + accessKeyEnv: DEVELOCITY_ACCESS_KEY + runs-on: ${{ matrix.os }} steps: - name: Checkout sources @@ -61,4 +66,83 @@ jobs: uses: actions/github-script@v7 with: script: | - core.setFailed('No Build Scan detected') + core.setFailed('No Build Scan detected') + - name: Check short lived token + if: ${{ matrix.plugin-version == '3.17.3' }} + run: "[ ${#DEVELOCITY_ACCESS_KEY} -gt 500 ] || (echo 'DEVELOCITY_ACCESS_KEY does not look like a short lived token'; exit 1)" + + inject-develocity-with-access-key: + env: + DEVELOCITY_INJECTION_ENABLED: true + DEVELOCITY_URL: 'https://ge.solutions-team.gradle.com' + DEVELOCITY_PLUGIN_VERSION: 3.17.3 + DEVELOCITY_CCUD_PLUGIN_VERSION: '2.0' + strategy: + fail-fast: false + matrix: + gradle: [current, 7.6.2, 6.9.4, 5.6.4] + os: ${{fromJSON(inputs.runner-os)}} + runs-on: ${{ matrix.os }} + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Initialize integ-test + uses: ./.github/actions/init-integ-test + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 8 + - name: Setup Gradle + id: setup-gradle + uses: ./setup-gradle + with: + cache-read-only: false # For testing, allow writing cache entries on non-default branches + gradle-version: ${{ matrix.gradle }} + develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + develocity-token-expiry: 1 + - name: Run Gradle build + id: gradle + working-directory: .github/workflow-samples/no-ge + run: gradle help + - name: Check short lived token + run: "[ ${#DEVELOCITY_ACCESS_KEY} -gt 500 ] || (echo 'DEVELOCITY_ACCESS_KEY does not look like a short lived token'; exit 1)" + - name: Check Build Scan url + if: ${{ !steps.gradle.outputs.build-scan-url }} + uses: actions/github-script@v7 + with: + script: | + core.setFailed('No Build Scan detected') + + inject-develocity-short-lived-token-failed: + env: + DEVELOCITY_INJECTION_ENABLED: true + DEVELOCITY_URL: 'https://localhost:3333/' + DEVELOCITY_PLUGIN_VERSION: 3.17.3 + DEVELOCITY_CCUD_PLUGIN_VERSION: '2.0' + # Access key also set as an env var, we want to check it does not leak + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Initialize integ-test + uses: ./.github/actions/init-integ-test + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 8 + - name: Setup Gradle + id: setup-gradle + uses: ./setup-gradle + with: + cache-read-only: false # For testing, allow writing cache entries on non-default branches + develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + - name: Run Gradle build + id: gradle + working-directory: .github/workflow-samples/no-ge + run: gradle help + - name: Check access key is blank + run: "[ \"${DEVELOCITY_ACCESS_KEY}\" == \"\" ] || (echo 'DEVELOCITY_ACCESS_KEY has leaked!'; exit 1)" diff --git a/build b/build index 9b23141..e31cde7 100755 --- a/build +++ b/build @@ -16,12 +16,18 @@ case "$1" in # Run act $@ # Revert the changes to the dist directory - git co -- dist + git checkout -- dist ;; init-scripts) cd test/init-scripts ./gradlew check ;; + dist) + npm install + npm run build + cd .. + cp -r sources/dist . + ;; *) npm install npm run build diff --git a/dependency-submission/action.yml b/dependency-submission/action.yml index 3835535..45b7ee7 100644 --- a/dependency-submission/action.yml +++ b/dependency-submission/action.yml @@ -15,13 +15,13 @@ inputs: dependency-resolution-task: description: | - Task(s) that should be executed in order to resolve all project dependencies. + Task(s) that should be executed in order to resolve all project dependencies. By default, the built-in `:ForceDependencyResolutionPlugin_resolveAllDependencies` task is executed. required: false additional-arguments: description: | - Additional arguments to pass to Gradle when generating the dependency graph. + Additional arguments to pass to Gradle when generating the dependency graph. For example, `--no-configuration-cache --stacktrace`. required: false @@ -40,7 +40,7 @@ inputs: cache-write-only: description: | - When 'true', entries will not be restored from the cache but will be saved at the end of the Job. + When 'true', entries will not be restored from the cache but will be saved at the end of the Job. Setting this to 'true' implies cache-read-only will be 'false'. required: false default: false @@ -52,7 +52,7 @@ inputs: cache-encryption-key: description: | - A base64 encoded AES key used to encrypt the configuration-cache data. The key is exported as 'GRADLE_ENCRYPTION_KEY' for later steps. + A base64 encoded AES key used to encrypt the configuration-cache data. The key is exported as 'GRADLE_ENCRYPTION_KEY' for later steps. A suitable key can be generated with `openssl rand -base64 16`. Configuration-cache data will not be saved/restored without an encryption key being provided. required: false @@ -92,7 +92,7 @@ inputs: 'generate-and-submit' (default): Generates a dependency graph for the project and submits it in the same Job. 'generate-and-upload': Generates a dependency graph for the project and saves it as a workflow artifact. 'download-and-submit': Retrieves a previously saved dependency-graph and submits it to the repository. - + The `generate-and-upload` and `download-and-submit` options are designed to be used in an untrusted workflow scenario, where the workflow generating the dependency-graph cannot (or should not) be given the `contents: write` permissions required to submit via the Dependency Submission API. @@ -120,11 +120,19 @@ inputs: build-scan-terms-of-use-url: description: The URL to the Build Scan® terms of use. This input must be set to 'https://gradle.com/terms-of-service' or 'https://gradle.com/help/legal-terms-of-use'. required: false - + build-scan-terms-of-use-agree: description: Indicate that you agree to the Build Scan® terms of use. This input value must be "yes". required: false + develocity-access-key: + description: Develocity access key. Should be set to a secret containing the Develocity Access key. + required: false + + develocity-token-expiry: + description: The Develocity short-lived access tokens expiry in hours. Default is 2 hours. + required: false + # DEPRECATED ACTION INPUTS build-scan-terms-of-service-url: description: The URL to the Build Scan® terms of use. This input must be set to 'https://gradle.com/terms-of-service'. @@ -150,7 +158,7 @@ inputs: description: When 'true', the action will not attempt to restore the Gradle User Home entries from other Jobs. required: false default: false - + # INTERNAL ACTION INPUTS # These inputs should not be configured directly, and are only used to pass environmental information to the action workflow-job-context: diff --git a/docs/setup-gradle.md b/docs/setup-gradle.md index 5a13176..61348d7 100644 --- a/docs/setup-gradle.md +++ b/docs/setup-gradle.md @@ -4,22 +4,22 @@ This GitHub Action can be used to configure Gradle for optimal execution on any ## Why use the `setup-gradle` action? -It is possible to directly invoke Gradle in your workflow, and the `actions/setup-java@v4` action provides a simple way to cache Gradle dependencies. +It is possible to directly invoke Gradle in your workflow, and the `actions/setup-java@v4` action provides a simple way to cache Gradle dependencies. However, the `setup-gradle` action offers a several advantages over this approach: -- Easily [configure your workflow to use a specific version of Gradle](#build-with-a-specific-gradle-version) using the `gradle-version` parameter. Gradle distributions are automatically downloaded and cached. +- Easily [configure your workflow to use a specific version of Gradle](#build-with-a-specific-gradle-version) using the `gradle-version` parameter. Gradle distributions are automatically downloaded and cached. - More sophisticated and more efficient caching of Gradle User Home between invocations, compared to `setup-java` and most custom configurations using `actions/cache`. [More details below](#caching-build-state-between-jobs). - Detailed reporting of cache usage and cache configuration options allow you to [optimize the use of the GitHub actions cache](#optimizing-cache-effectiveness). - [Generate and Submit a GitHub Dependency Graph](#github-dependency-graph-support) for your project, enabling Dependabot security alerts. - [Automatic capture of Build Scan® links](#build-reporting) from the build, making them easier to locate in workflow runs. -The `setup-gradle` action is designed to provide these benefits with minimal configuration. +The `setup-gradle` action is designed to provide these benefits with minimal configuration. These features work both when Gradle is executed via `setup-gradle` and for any Gradle execution in subsequent steps. ## General usage -The `setup-gradle` action works by configuring environment variables and by adding a set of Gradle init-scripts to the Gradle User Home. These will apply to all Gradle executions on the runner, no matter how Gradle is invoked. +The `setup-gradle` action works by configuring environment variables and by adding a set of Gradle init-scripts to the Gradle User Home. These will apply to all Gradle executions on the runner, no matter how Gradle is invoked. This means that if you have an existing workflow that executes Gradle with a `run` step, you can add an initial "Setup Gradle" Step to benefit from caching, build-scan capture, and other features of this action. The recommended way to execute any Gradle build is with the help of the [Gradle Wrapper](https://docs.gradle.org/current/userguide/gradle_wrapper.html), and the following examples assume that the Gradle Wrapper has been configured for the project. See [this example](#build-with-a-specific-gradle-version) if your project doesn't use the Gradle Wrapper. @@ -46,7 +46,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - + - name: Execute Gradle build run: ./gradlew build ``` @@ -126,7 +126,7 @@ cache-disabled: true ### Using the cache read-only By default, The `setup-gradle` action will only write to the cache from Jobs on the default (`main`/`master`) branch. -Jobs on other branches will read entries from the cache but will not write updated entries. +Jobs on other branches will read entries from the cache but will not write updated entries. See [Optimizing cache effectiveness](#select-which-branches-should-write-to-the-cache) for a more detailed explanation. In some circumstances, it makes sense to change this default and configure a workflow Job to read existing cache entries but not to write changes back. @@ -158,7 +158,7 @@ cache-write-only: true When the action detects that the Gradle User Home caches directory already exists (`~/.gradle/caches`), then by default it will not overwrite the existing content of this directory. This can occur when a prior action initializes this directory, or when using a self-hosted runner that retains this directory between uses. -In this case, the Job Summary will display a message like: +In this case, the Job Summary will display a message like: > Caching for Gradle actions was disabled due to pre-existing Gradle User Home If you want to override the default and have the caches of the `setup-gradle` action overwrite existing content in the Gradle User Home, you can set the `cache-overwrite-existing` parameter to `true`: @@ -204,7 +204,7 @@ jobs: ### Incompatibility with other caching mechanisms -When using `setup-gradle` we recommend that you avoid using other mechanisms to save and restore the Gradle User Home. +When using `setup-gradle` we recommend that you avoid using other mechanisms to save and restore the Gradle User Home. Specifically: - Avoid using `actions/cache` configured to cache the Gradle User Home, [as described in this example](https://github.com/actions/cache/blob/main/examples.md#java---gradle). @@ -256,7 +256,7 @@ The Gradle User Home cache key is composed of: Specifically, the cache key is: `${cache-protocol}-gradle|${runner-os}|${job-id}[${hash-of-job-matrix-and-workflow-name}]-${git-sha}` -As such, the cache key is likely to change on each subsequent run of GitHub actions. +As such, the cache key is likely to change on each subsequent run of GitHub actions. This allows the most recent state to always be available in the GitHub actions cache. ### Finding a matching cache entry @@ -271,7 +271,7 @@ Due to branch scoping of cache entries, the above match will be first performed After the Job is complete, the current Gradle User Home state will be collected and written as a new cache entry with the complete cache key. Old entries will be expunged from the GitHub Actions cache on a least recently used basis. -Note that while effective, this mechanism is not inherently efficient. It requires the entire Gradle User Home directory to be stored separately for each branch, for every OS+Job+Matrix combination. In addition, it writes a new cache entry on every GitHub Actions run. +Note that while effective, this mechanism is not inherently efficient. It requires the entire Gradle User Home directory to be stored separately for each branch, for every OS+Job+Matrix combination. In addition, it writes a new cache entry on every GitHub Actions run. This inefficiency is effectively mitigated by [Deduplication of Gradle User Home cache entries](#deduplication-of-gradle-user-home-cache-entries) and can be further optimized for a workflow using the techniques described in [Optimizing cache effectiveness](#optimizing-cache-effectiveness). @@ -289,7 +289,7 @@ For example, this means that all jobs executing a particular version of the Grad ### Stopping the Gradle daemon -By default, the action will stop all running Gradle daemons in the post-action step, before saving the Gradle User Home state. +By default, the action will stop all running Gradle daemons in the post-action step, before saving the Gradle User Home state. This allows for any Gradle User Home cleanup to occur, and avoid file-locking issues on Windows. If caching is disabled or the cache is in read-only mode, the daemon will not be stopped and will continue running after the job is completed. @@ -316,23 +316,23 @@ Some techniques can be used to avoid/mitigate this issue: ### Select which branches should write to the cache -GitHub cache entries are not shared between builds on different branches. +GitHub cache entries are not shared between builds on different branches. Workflow runs can restore caches created in either the current branch or the default branch (usually main). This means that each branch will have its own Gradle User Home cache scope, and will not benefit from cache entries written for other (non-default) branches. -By default, The `setup-gradle` action will only _write_ to the cache for builds run on the default (`master`/`main`) branch. -Jobs running on other branches will only read from the cache. In most cases, this is the desired behavior. -This is because Jobs running on other branches will benefit from the cached Gradle User Home from `main`, +By default, The `setup-gradle` action will only _write_ to the cache for builds run on the default (`master`/`main`) branch. +Jobs running on other branches will only read from the cache. In most cases, this is the desired behavior. +This is because Jobs running on other branches will benefit from the cached Gradle User Home from `main`, without writing private cache entries which could lead to evicting these shared entries. -If you have other long-lived development branches that would benefit from writing to the cache, -you can configure this by disabling the `cache-read-only` action parameter for these branches. +If you have other long-lived development branches that would benefit from writing to the cache, +you can configure this by disabling the `cache-read-only` action parameter for these branches. See [Using the cache read-only](#using-the-cache-read-only) for more details. Note there are some cases where writing cache entries is typically unhelpful (these are disabled by default): - For `pull_request` triggered runs, the cache scope is limited to the merge ref (`refs/pull/.../merge`) and can only be restored by re-runs of the same pull request. - For `merge_group` triggered runs, the cache scope is limited to a temporary branch with a special prefix created to validate pull request changes, and won't be available on subsequent Merge Queue executions. - + ### Exclude content from Gradle User Home cache As well as any wrapper distributions, the action will attempt to save and restore the `caches` and `notifications` directories from Gradle User Home. @@ -353,7 +353,7 @@ gradle-home-cache-excludes: | caches/keyrings ``` -You can specify any number of fixed paths or patterns to include or exclude. +You can specify any number of fixed paths or patterns to include or exclude. File pattern support is documented at https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#patterns-to-match-file-paths. ### Remove unused files from Gradle User Home before saving to the cache @@ -362,7 +362,7 @@ The Gradle User Home directory tends to grow over time. When you switch to a new the old files are not automatically and immediately removed. While this can make sense in a local environment, in a GitHub Actions environment it can lead to ever-larger Gradle User Home cache entries being saved and restored. -To avoid this situation, The `setup-gradle` action supports the `gradle-home-cache-cleanup` parameter. +To avoid this situation, The `setup-gradle` action supports the `gradle-home-cache-cleanup` parameter. When enabled, this feature will attempt to delete any files in the Gradle User Home that were not used by Gradle during the GitHub Actions Workflow, before saving the Gradle User Home to the GitHub Actions cache. Gradle Home cache cleanup is considered experimental and is disabled by default. You can enable this feature for the action as follows: @@ -376,7 +376,7 @@ If you have a remote build-cache available for your build, then it is recommende - Enable [remote build-cache push](https://docs.gradle.org/current/userguide/build_cache.html#sec:build_cache_configure_use_cases) for your GitHub Actions builds - Disable [local build-cache]() for your GitHub Actions build -As well as reducing the content that needs to be saved to the GitHub Actions cache, +As well as reducing the content that needs to be saved to the GitHub Actions cache, this setup will ensure that your CI builds populate the remote cache and keep the cache entries fresh by reading these entries. Local builds can then benefit from the remote cache. @@ -389,8 +389,8 @@ You can enable debug logging either by: ### Increased logging from Gradle builds -When debug logging is enabled, this action will cause all builds to run with the `--info` and `--stacktrace` options. -This is done by inserting the relevant [Gradle properties](https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties) +When debug logging is enabled, this action will cause all builds to run with the `--info` and `--stacktrace` options. +This is done by inserting the relevant [Gradle properties](https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties) at the top of the `${GRADLE_USER_HOME}/gradle.properties` file. If the additional Gradle logging produced is problematic, you may opt out of this behavior by setting these properties manually in your project `gradle.properties` file: @@ -403,7 +403,7 @@ org.gradle.logging.stacktrace=internal ### Cache debugging and analysis -A report of all cache entries restored and saved is printed to the Job Summary when saving the cache entries. +A report of all cache entries restored and saved is printed to the Job Summary when saving the cache entries. This report can provide valuable insight into how much cache space is being used. When debug logging is enabled, more detailed logging of cache operations is included in the GitHub actions log. @@ -461,18 +461,18 @@ Note that to add a Pull Request comment, the workflow must be configured with th As well as reporting all [Build Scan](https://gradle.com/build-scans/) links in the Job Summary, The `setup-gradle` action makes this link available as an output of any Step that executes Gradle. -The output name is `build-scan-url`. You can then use the build scan link in subsequent actions of your workflow. +The output name is `build-scan-url`. You can then use the build scan link in subsequent actions of your workflow. ### Saving arbitrary build outputs -By default, a GitHub Actions workflow using `setup-gradle` will record the log output and any Build Scan +By default, a GitHub Actions workflow using `setup-gradle` will record the log output and any Build Scan links for your build, but any output files generated by the build will not be saved. To save selected files from your build execution, you can use the core [Upload-Artifact](https://github.com/actions/upload-artifact) action. For example: ```yaml -jobs: +jobs: gradle: runs-on: ubuntu-latest steps: @@ -506,7 +506,7 @@ Since Gradle applies init scripts in alphabetical order, one way to ensure this ## Gradle Wrapper validation -Instead of using the [wrapper-validation action](./wrapper-validation.md) separately, you can enable +Instead of using the [wrapper-validation action](./wrapper-validation.md) separately, you can enable wrapper validation directly in your Setup Gradle step. ```yaml @@ -530,7 +530,7 @@ You can use the `setup-gradle` action on GitHub Enterprise Server, and benefit f ## GitHub Dependency Graph support > [!IMPORTANT] -> The simplest (and recommended) way to generate a dependency graph is via a separate workflow +> The simplest (and recommended) way to generate a dependency graph is via a separate workflow > using `gradle/actions/dependency-submission`. This action will attempt to detect all dependencies used by your build > without building and testing the project itself. > @@ -544,7 +544,7 @@ The dependency graph snapshot is generated via integration with the [GitHub Depe The generated dependency graph snapshot reports all of the dependencies that were resolved during a build execution, and is used by GitHub to generate [Dependabot Alerts](https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts) for vulnerable dependencies, as well as to populate the [Dependency Graph insights view](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/exploring-the-dependencies-of-a-repository#viewing-the-dependency-graph). ### Basic usage - + You enable GitHub Dependency Graph support by setting the `dependency-graph` action parameter. Valid values are: | Option | Behaviour | @@ -560,7 +560,7 @@ Example of a CI workflow that generates and submits a dependency graph: name: CI build on: push: - + permissions: contents: write @@ -582,13 +582,13 @@ jobs: run: ./gradlew build ``` -The `contents: write` permission is required to submit (but not generate) the dependency graph file. +The `contents: write` permission is required to submit (but not generate) the dependency graph file. Depending on [repository settings](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token), this permission may be available by default or may need to be explicitly enabled in the workflow file (as above). > [!IMPORTANT] -> The above configuration will work for workflows that run as a result of commits to a repository branch, +> The above configuration will work for workflows that run as a result of commits to a repository branch, > but not when a workflow is triggered by a PR from a repository fork. -> This is because the `contents: write` permission is not available when executing a workflow +> This is because the `contents: write` permission is not available when executing a workflow > for a PR submitted from a forked repository. > For a configuration that supports this setup, see [Dependency Graphs for pull request workflows](dependency-submission.md#usage-with-pull-requests-from-public-forked-repositories). @@ -610,7 +610,7 @@ graph cannot be generated or submitted. You can enable this behavior with the `d ### Using a custom plugin repository -By default, the action downloads the `github-dependency-graph-gradle-plugin` from the Gradle Plugin Portal (https://plugins.gradle.org). If your GitHub Actions environment does not have access to this URL, you can specify a custom plugin repository to use. +By default, the action downloads the `github-dependency-graph-gradle-plugin` from the Gradle Plugin Portal (https://plugins.gradle.org). If your GitHub Actions environment does not have access to this URL, you can specify a custom plugin repository to use. Do so by setting the `GRADLE_PLUGIN_REPOSITORY_URL` environment variable with your Gradle invocation. The `GRADLE_PLUGIN_REPOSITORY_USERNAME` and `GRADLE_PLUGIN_REPOSITORY_PASSWORD` can be used when the plugin repository requires authentication. @@ -670,7 +670,7 @@ jobs: ### Filtering which Gradle Configurations contribute to the dependency graph -If you do not want the dependency graph to include every dependency configuration in every project in your build, +If you do not want the dependency graph to include every dependency configuration in every project in your build, you can limit the dependency extraction to a subset of these. See the documentation for [dependency-submission](dependency-submission.md) and the @@ -678,7 +678,7 @@ See the documentation for [dependency-submission](dependency-submission.md) and ### Gradle version compatibility -Dependency-graph generation is compatible with most versions of Gradle >= `5.2`, and is tested regularly against +Dependency-graph generation is compatible with most versions of Gradle >= `5.2`, and is tested regularly against Gradle versions `5.2.1`, `5.6.4`, `6.0.1`, `6.9.4`, `7.1.1` and `7.6.3`, as well as all patched versions of Gradle 8.x. A known exception to this is that Gradle `7.0`, `7.0.1`, and `7.0.2` are not supported. @@ -687,7 +687,7 @@ See [here](https://github.com/gradle/github-dependency-graph-gradle-plugin?tab=r ### Reducing storage costs for saved dependency graph artifacts -When `generate` or `generate-and-submit` is used with the action, the dependency graph that is generated is stored as a workflow artifact. +When `generate` or `generate-and-submit` is used with the action, the dependency graph that is generated is stored as a workflow artifact. By default, these artifacts are retained for 30 days (or as configured for the repository). To reduce storage costs for these artifacts, you can set the `artifact-retention-days` value to a lower number. @@ -708,7 +708,7 @@ The same auto-injection behavior is available for the Common Custom User Data Gr ## Enabling Develocity injection -To enable Develocity injection for your build, you must provide the required configuration via environment variables. +To enable Develocity injection for your build, you must provide the required configuration via environment variables. Here's a minimal example: @@ -727,8 +727,29 @@ Here's a minimal example: This configuration will automatically apply `v3.17.3` of the [Develocity Gradle plugin](https://docs.gradle.com/develocity/gradle-plugin/), and publish build scans to https://develocity.your-server.com. This example assumes that the `develocity.your-server.com` server allows anonymous publishing of build scans. -In the likely scenario that your Develocity server requires authentication, you will also need to configure an additional environment variable -with a valid [Develocity access key](https://docs.gradle.com/develocity/gradle-plugin/#via_environment_variable). +In the likely scenario that your Develocity server requires authentication, you will also need to pass a valid [Develocity access key](https://docs.gradle.com/develocity/gradle-plugin/#via_environment_variable) taken from a secret: + +```yaml + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + develocity-access-key: ${{ secrets.MY_DEVELOCITY_ACCESS_KEY }} + + - name: Run a Gradle build with Develocity injection enabled + run: ./gradlew build + env: + DEVELOCITY_INJECTION_ENABLED: true + DEVELOCITY_URL: https://develocity.your-server.com + DEVELOCITY_PLUGIN_VERSION: 3.17 +``` + +This access key will be used during the action execution to get a short-lived token and set it to the DEVELOCITY_ACCESS_KEY environment variable. + +### Short-lived access tokens +Develocity access keys are long-lived, creating risks if they are leaked. To avoid this, users can use short-lived access tokens to authenticate with Develocity. Access tokens can be used wherever an access key would be used. Access tokens are only valid for the Develocity instance that created them. +If a short-lived token fails to be retrieved (for example, if the Develocity server version is lower than `2024.1`), no access key will be set. +In that case, Develocity authenticated operations like build cache read/write and build scan publication will fail without failing the build. +For more information on short-lived tokens, see [Develocity API documentation](https://docs.gradle.com/develocity/api-manual/#short_lived_access_tokens). ## Configuring Develocity injection diff --git a/setup-gradle/action.yml b/setup-gradle/action.yml index 48ec0f3..f768c52 100644 --- a/setup-gradle/action.yml +++ b/setup-gradle/action.yml @@ -23,7 +23,7 @@ inputs: cache-write-only: description: | - When 'true', entries will not be restored from the cache but will be saved at the end of the Job. + When 'true', entries will not be restored from the cache but will be saved at the end of the Job. Setting this to 'true' implies cache-read-only will be 'false'. required: false default: false @@ -35,7 +35,7 @@ inputs: cache-encryption-key: description: | - A base64 encoded AES key used to encrypt the configuration-cache data. The key is exported as 'GRADLE_ENCRYPTION_KEY' for later steps. + A base64 encoded AES key used to encrypt the configuration-cache data. The key is exported as 'GRADLE_ENCRYPTION_KEY' for later steps. A suitable key can be generated with `openssl rand -base64 16`. Configuration-cache data will not be saved/restored without an encryption key being provided. required: false @@ -70,7 +70,7 @@ inputs: # Dependency Graph configuration dependency-graph: description: | - Specifies if a GitHub dependency snapshot should be generated for each Gradle build, and if so, how. + Specifies if a GitHub dependency snapshot should be generated for each Gradle build, and if so, how. Valid values are 'disabled' (default), 'generate', 'generate-and-submit', 'generate-and-upload', 'download-and-submit' and 'clear'. required: false default: 'disabled' @@ -95,11 +95,19 @@ inputs: build-scan-terms-of-use-url: description: The URL to the Build Scan® terms of use. This input must be set to 'https://gradle.com/terms-of-service' or 'https://gradle.com/help/legal-terms-of-use'. required: false - + build-scan-terms-of-use-agree: description: Indicate that you agree to the Build Scan® terms of use. This input value must be "yes". required: false + develocity-access-key: + description: Develocity access key. Should be set to a secret containing the Develocity Access key. + required: false + + develocity-token-expiry: + description: The Develocity short-lived access tokens expiry in hours. Default is 2 hours. + required: false + # Wrapper validation configuration validate-wrappers: description: | @@ -143,7 +151,7 @@ inputs: description: When 'true', the action will not attempt to restore the Gradle User Home entries from other Jobs. required: false default: false - + # INTERNAL ACTION INPUTS # These inputs should not be configured directly, and are only used to pass environmental information to the action workflow-job-context: diff --git a/sources/src/configuration.ts b/sources/src/configuration.ts index 9eb2291..fe83e0c 100644 --- a/sources/src/configuration.ts +++ b/sources/src/configuration.ts @@ -200,6 +200,14 @@ export class BuildScanConfig { return this.getTermsOfUseProp('build-scan-terms-of-use-agree', 'build-scan-terms-of-service-agree') } + getDevelocityAccessKey(): string { + return core.getInput('develocity-access-key') || process.env['DEVELOCITY_ACCESS_KEY'] || '' + } + + getDevelocityTokenExpiry(): string { + return core.getInput('develocity-token-expiry') + } + private verifyTermsOfUseAgreement(): boolean { if ( (this.getBuildScanTermsOfUseUrl() !== 'https://gradle.com/terms-of-service' && diff --git a/sources/src/build-scan.ts b/sources/src/develocity/build-scan.ts similarity index 65% rename from sources/src/build-scan.ts rename to sources/src/develocity/build-scan.ts index 352efd8..4e696dc 100644 --- a/sources/src/build-scan.ts +++ b/sources/src/develocity/build-scan.ts @@ -1,7 +1,8 @@ import * as core from '@actions/core' -import {BuildScanConfig} from './configuration' +import {BuildScanConfig} from '../configuration' +import {setupToken} from './short-lived-token' -export function setup(config: BuildScanConfig): void { +export async function setup(config: BuildScanConfig): Promise { maybeExportVariable('DEVELOCITY_INJECTION_INIT_SCRIPT_NAME', 'gradle-actions.inject-develocity.init.gradle') maybeExportVariable('DEVELOCITY_AUTO_INJECTION_CUSTOM_VALUE', 'gradle-actions') if (config.getBuildScanPublishEnabled()) { @@ -11,6 +12,16 @@ export function setup(config: BuildScanConfig): void { maybeExportVariable('DEVELOCITY_TERMS_OF_USE_URL', config.getBuildScanTermsOfUseUrl()) maybeExportVariable('DEVELOCITY_TERMS_OF_USE_AGREE', config.getBuildScanTermsOfUseAgree()) } + setupToken( + config.getDevelocityAccessKey(), + config.getDevelocityTokenExpiry(), + getEnv('DEVELOCITY_ENFORCE_URL'), + getEnv('DEVELOCITY_URL') + ) +} + +function getEnv(variableName: string): string | undefined { + return process.env[variableName] } function maybeExportVariable(variableName: string, value: unknown): void { diff --git a/sources/src/develocity/short-lived-token.ts b/sources/src/develocity/short-lived-token.ts new file mode 100644 index 0000000..148323e --- /dev/null +++ b/sources/src/develocity/short-lived-token.ts @@ -0,0 +1,191 @@ +import * as httpm from 'typed-rest-client/HttpClient' +import * as core from '@actions/core' + +export async function setupToken( + develocityAccessKey: string, + develocityTokenExpiry: string, + enforceUrl: string | undefined, + develocityUrl: string | undefined +): Promise { + const develocityAccesskeyEnvVar = 'DEVELOCITY_ACCESS_KEY' + if (develocityAccessKey) { + try { + core.debug('Fetching short-lived token...') + const tokens = await getToken(enforceUrl, develocityUrl, develocityAccessKey, develocityTokenExpiry) + if (tokens != null && !tokens.isEmpty()) { + core.debug(`Got token(s), setting the ${develocityAccesskeyEnvVar} env var`) + const token = tokens.raw() + core.setSecret(token) + core.exportVariable(develocityAccesskeyEnvVar, token) + } else { + // In case of not being able to generate a token we set the env variable to empty to avoid leaks + core.exportVariable(develocityAccesskeyEnvVar, '') + } + } catch (e) { + core.exportVariable(develocityAccesskeyEnvVar, '') + core.warning(`Failed to fetch short-lived token, reason: ${e}`) + } + } +} + +export async function getToken( + enforceUrl: string | undefined, + serverUrl: string | undefined, + accessKey: string, + expiry: string +): Promise { + const empty: Promise = new Promise(r => r(null)) + const develocityAccessKey = DevelocityAccessCredentials.parse(accessKey) + const shortLivedTokenClient = new ShortLivedTokenClient() + + async function promiseError(message: string): Promise { + return new Promise((resolve, reject) => reject(new Error(message))) + } + + if (develocityAccessKey == null) { + return empty + } + if (enforceUrl === 'true' || develocityAccessKey.isSingleKey()) { + if (!serverUrl) { + return promiseError('Develocity Server URL not configured') + } + const hostname = extractHostname(serverUrl) + if (hostname == null) { + return promiseError('Could not extract hostname from Develocity server URL') + } + const hostAccessKey = develocityAccessKey.forHostname(hostname) + if (!hostAccessKey) { + return promiseError(`Could not find corresponding key for hostname ${hostname}`) + } + try { + const token = await shortLivedTokenClient.fetchToken(serverUrl, hostAccessKey, expiry) + return DevelocityAccessCredentials.of([token]) + } catch (e) { + return new Promise((resolve, reject) => reject(e)) + } + } + + const tokens = new Array() + for (const k of develocityAccessKey.keys) { + try { + const token = await shortLivedTokenClient.fetchToken(`https://${k.hostname}`, k, expiry) + tokens.push(token) + } catch (e) { + // Ignoring failed token, TODO: log this ? + } + } + if (tokens.length > 0) { + return DevelocityAccessCredentials.of(tokens) + } + return empty +} + +function extractHostname(serverUrl: string): string | null { + try { + const parsedUrl = new URL(serverUrl) + return parsedUrl.hostname + } catch (error) { + return null + } +} + +class ShortLivedTokenClient { + httpc = new httpm.HttpClient('gradle/setup-gradle') + maxRetries = 3 + retryInterval = 1000 + + async fetchToken(serverUrl: string, accessKey: HostnameAccessKey, expiry: string): Promise { + const queryParams = expiry ? `?expiresInHours${expiry}` : '' + const sanitizedServerUrl = !serverUrl.endsWith('/') ? `${serverUrl}/` : serverUrl + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessKey.key}` + } + + let attempts = 0 + while (attempts < this.maxRetries) { + try { + const requestUrl = `${sanitizedServerUrl}api/auth/token${queryParams}` + core.debug(`Attempt ${attempts} to fetch short lived token at ${requestUrl}`) + const response = await this.httpc.post(requestUrl, '', headers) + if (response.message.statusCode === 200) { + const text = await response.readBody() + return new Promise(resolve => resolve({hostname: accessKey.hostname, key: text})) + } + // This should be only 404 + attempts++ + if (attempts === this.maxRetries) { + return new Promise((resolve, reject) => + reject( + new Error( + `Develocity short lived token request failed ${serverUrl} with status code ${response.message.statusCode}` + ) + ) + ) + } + } catch (error) { + attempts++ + if (attempts === this.maxRetries) { + return new Promise((resolve, reject) => reject(error)) + } + } + await new Promise(resolve => setTimeout(resolve, this.retryInterval)) + } + return new Promise((resolve, reject) => reject(new Error('Illegal state'))) + } +} + +type HostnameAccessKey = { + hostname: string + key: string +} + +export class DevelocityAccessCredentials { + static readonly accessKeyRegexp = /^(\S+=\w+)(;\S+=\w+)*$/ + readonly keys: HostnameAccessKey[] + + private constructor(allKeys: HostnameAccessKey[]) { + this.keys = allKeys + } + + static of(allKeys: HostnameAccessKey[]): DevelocityAccessCredentials { + return new DevelocityAccessCredentials(allKeys) + } + + private static readonly keyDelimiter = ';' + private static readonly hostDelimiter = '=' + + static parse(rawKey: string): DevelocityAccessCredentials | null { + if (!this.isValid(rawKey)) { + return null + } + return new DevelocityAccessCredentials( + rawKey.split(this.keyDelimiter).map(hostKey => { + const pair = hostKey.split(this.hostDelimiter) + return {hostname: pair[0], key: pair[1]} + }) + ) + } + + isEmpty(): boolean { + return this.keys.length === 0 + } + + isSingleKey(): boolean { + return this.keys.length === 1 + } + + forHostname(hostname: string): HostnameAccessKey | undefined { + return this.keys.find(hostKey => hostKey.hostname === hostname) + } + + raw(): string { + return this.keys + .map(k => `${k.hostname}${DevelocityAccessCredentials.hostDelimiter}${k.key}`) + .join(DevelocityAccessCredentials.keyDelimiter) + } + + private static isValid(allKeys: string): boolean { + return this.accessKeyRegexp.test(allKeys) + } +} diff --git a/sources/src/setup-gradle.ts b/sources/src/setup-gradle.ts index bcf2d39..6556e36 100644 --- a/sources/src/setup-gradle.ts +++ b/sources/src/setup-gradle.ts @@ -4,7 +4,7 @@ import * as path from 'path' import * as os from 'os' import * as caches from './caching/caches' import * as jobSummary from './job-summary' -import * as buildScan from './build-scan' +import * as buildScan from './develocity/build-scan' import {loadBuildResults, markBuildResultsProcessed} from './build-results' import {CacheListener, generateCachingReport} from './caching/cache-reporting' @@ -41,7 +41,7 @@ export async function setup(cacheConfig: CacheConfig, buildScanConfig: BuildScan core.saveState(CACHE_LISTENER, cacheListener.stringify()) - buildScan.setup(buildScanConfig) + await buildScan.setup(buildScanConfig) return true } diff --git a/sources/test/jest/short-lived-token.test.ts b/sources/test/jest/short-lived-token.test.ts new file mode 100644 index 0000000..b2f07a5 --- /dev/null +++ b/sources/test/jest/short-lived-token.test.ts @@ -0,0 +1,137 @@ +import {DevelocityAccessCredentials, getToken} from "../../src/develocity/short-lived-token"; +import nock from "nock"; + +describe('short lived tokens', () => { + it('parse valid access key should return an object', async () => { + let develocityAccessCredentials = DevelocityAccessCredentials.parse('some-host.local=key1;host2=key2'); + + expect(develocityAccessCredentials).toStrictEqual(DevelocityAccessCredentials.of([ + {hostname: 'some-host.local', key: 'key1'}, + {hostname: 'host2', key: 'key2'}]) + ) + }) + + it('parse wrong access key should return null', async () => { + let develocityAccessCredentials = DevelocityAccessCredentials.parse('random;foo'); + + expect(develocityAccessCredentials).toBeNull() + }) + + it('parse empty access key should return null', async () => { + let develocityAccessCredentials = DevelocityAccessCredentials.parse(''); + + expect(develocityAccessCredentials).toBeNull() + }) + + it('access key as raw string', async () => { + let develocityAccessCredentials = DevelocityAccessCredentials.parse('host1=key1;host2=key2'); + + expect(develocityAccessCredentials?.raw()).toBe('host1=key1;host2=key2') + }) + + it('get short lived token fails when cannot connect', async () => { + nock('http://localhost:3333') + .post('/api/auth/token') + .times(3) + .replyWithError({ + message: 'connect ECONNREFUSED 127.0.0.1:3333', + code: 'ECONNREFUSED' + }) + try { + await getToken('true', 'http://localhost:3333', 'localhost=xyz;host1=key1', '') + expect('should have thrown').toBeUndefined() + } catch (e) { + // @ts-ignore + expect(e.code).toBe('ECONNREFUSED') + } + }) + + it('get short lived token fails when request fails', async () => { + nock('http://dev:3333') + .post('/api/auth/token') + .times(3) + .reply(500, 'Internal error') + expect.assertions(1) + await expect(getToken('true', 'http://dev:3333', 'dev=xyz;host1=key1', '')) + .rejects + .toThrow('Develocity short lived token request failed http://dev:3333 with status code 500') + }) + + it('get short lived token fails when server url is not set', async () => { + expect.assertions(1) + await expect(getToken('true', undefined, 'localhost=xyz;host1=key1', '')) + .rejects + .toThrow('Develocity Server URL not configured') + }) + + it('get short lived token returns null when access key is empty', async () => { + expect.assertions(1) + await expect(getToken('true', 'http://dev:3333', '', '')) + .resolves + .toBeNull() + }) + + it('get short lived token fails when host cannot be extracted from server url', async () => { + expect.assertions(1) + await expect(getToken('true', 'not_a_url', 'localhost=xyz;host1=key1', '')) + .rejects + .toThrow('Could not extract hostname from Develocity server URL') + }) + + it('get short lived token fails when access key does not contain corresponding host', async () => { + expect.assertions(1) + await expect(getToken('true', 'http://dev', 'host1=xyz;host2=key2', '')) + .rejects + .toThrow('Could not find corresponding key for hostname dev') + }) + + it('get short lived token succeeds when enforce url is true', async () => { + nock('https://dev') + .post('/api/auth/token') + .reply(200, 'token') + expect.assertions(1) + await expect(getToken('true', 'https://dev', 'dev=key1;host1=key2', '')) + .resolves + .toEqual({"keys": [{"hostname": "dev", "key": "token"}]}) + }) + + it('get short lived token succeeds when enforce url is false and single key is set', async () => { + nock('https://dev') + .post('/api/auth/token') + .reply(200, 'token') + expect.assertions(1) + await expect(getToken('false', 'https://dev', 'dev=key1', '')) + .resolves + .toEqual({"keys": [{"hostname": "dev", "key": "token"}]}) + }) + + it('get short lived token succeeds when enforce url is false and multiple keys are set', async () => { + nock('https://dev') + .post('/api/auth/token') + .reply(200, 'token1') + nock('https://prod') + .post('/api/auth/token') + .reply(200, 'token2') + expect.assertions(1) + await expect(getToken('false', 'https://dev', 'dev=key1;prod=key2', '')) + .resolves + .toEqual({"keys": [{"hostname": "dev", "key": "token1"}, {"hostname": "prod", "key": "token2"}]}) + }) + + it('get short lived token succeeds when enforce url is false and multiple keys are set and one is failing', async () => { + nock('https://dev') + .post('/api/auth/token') + .reply(200, 'token1') + nock('https://bogus') + .post('/api/auth/token') + .times(3) + .reply(500, 'Internal Error') + nock('https://prod') + .post('/api/auth/token') + .reply(200, 'token2') + expect.assertions(1) + await expect(getToken('false', 'https://dev', 'dev=key1;bogus=key0;prod=key2', '')) + .resolves + .toEqual({"keys": [{"hostname": "dev", "key": "token1"}, {"hostname": "prod", "key": "token2"}]}) + }) +})