From 05e5bdebb62926ed6e979bd507a64d5e08356693 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 24 Aug 2026 06:17:38 +0000 Subject: [PATCH 1/6] Create GitHub App token plugin Amp-Thread-ID: https://ampcode.com/threads/T-01a0277c-8ac1-77fc-91b8-f1fc859fe02f Co-authored-by: Lachlan Donald --- .buildkite/pipeline.yml | 15 ++ .gitignore | 2 + Dockerfile.tests | 3 + LICENSE | 21 +++ README.md | 124 ++++++++++++- docker-compose.yml | 18 ++ hooks/pre-command | 8 + hooks/pre-exit | 8 + lib/plugin.bash | 375 ++++++++++++++++++++++++++++++++++++++++ plugin.yml | 76 ++++++++ tests/plugin.bats | 265 ++++++++++++++++++++++++++++ 11 files changed, 913 insertions(+), 2 deletions(-) create mode 100644 .buildkite/pipeline.yml create mode 100644 .gitignore create mode 100644 Dockerfile.tests create mode 100644 LICENSE create mode 100644 docker-compose.yml create mode 100755 hooks/pre-command create mode 100755 hooks/pre-exit create mode 100644 lib/plugin.bash create mode 100644 plugin.yml create mode 100755 tests/plugin.bats diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml new file mode 100644 index 0000000..50dba20 --- /dev/null +++ b/.buildkite/pipeline.yml @@ -0,0 +1,15 @@ +steps: + - label: ":bash: Tests" + plugins: + - docker-compose#v5.14.0: + run: tests + - label: ":shell: Shellcheck" + plugins: + - shellcheck#v1.4.0: + files: + - hooks/* + - lib/*.bash + - label: ":sparkles: Plugin lint" + plugins: + - plugin-linter#v3.3.0: + id: buildkite-plugins/create-github-app-token diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63e98d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +tmp/ diff --git a/Dockerfile.tests b/Dockerfile.tests new file mode 100644 index 0000000..c8c25e8 --- /dev/null +++ b/Dockerfile.tests @@ -0,0 +1,3 @@ +FROM buildkite/plugin-tester:v4.3.0 + +RUN apk --no-cache add openssl diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9276321 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Buildkite Pty Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index facb704..23576a4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,122 @@ -# create-github-app-token-buildkite-plugin -A Buildkite plugin to create short-lived GitHub App installation access tokens +# Create GitHub App Token Buildkite Plugin + +Creates a short-lived GitHub App installation access token for a command step. The token is registered with the Buildkite redactor before it is exported, narrowed to the current repository by default, and revoked in `pre-exit` after the job finishes. + +This is the Buildkite equivalent of [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token), with a reference-only private-key interface suitable for Buildkite Secrets and external secret-manager plugins. + +## Example using Buildkite Secrets + +Store the App private key as a Buildkite Secret named `GITHUB_APP_PRIVATE_KEY`. The plugin retrieves it only during `pre-command`, rather than injecting the long-lived private key into the whole job environment: + +```yaml +steps: + - command: gh api /user + plugins: + - buildkite-plugins/create-github-app-token#v1.0.0: + client-id: Iv1.0123456789abcdef + private-key-secret: GITHUB_APP_PRIVATE_KEY + permissions: + contents: read +``` + +`private-key-secret` requires a Buildkite agent that supports `buildkite-agent secret get`. Access to the secret must be allowed by the cluster's agent access policy. + +Alternatively, inject a Buildkite Secret into a named environment variable (agent v3.106.0+) and reference the **name**, not the value: + +```yaml +steps: + - command: git ls-remote "https://x-access-token:${GITHUB_TOKEN}@github.com/acme/private-repo.git" + secrets: + GITHUB_APP_KEY: GITHUB_APP_PRIVATE_KEY + plugins: + - buildkite-plugins/create-github-app-token#v1.0.0: + app-id: "123456" # Compatibility with Apps using a numeric App ID. + private-key-env: GITHUB_APP_KEY + owner: acme + repositories: + - private-repo + permissions: + contents: read +``` + +Never put the private key or an expanded environment variable in plugin configuration. Pipeline configuration is uploaded to Buildkite and may be visible to people who can inspect builds. This plugin deliberately has no inline `private-key` option. + +## Configuration + +### App identity + +- **`client-id`**: GitHub App client ID. Preferred by GitHub as the JWT issuer. +- **`client-id-env`**: Name of an environment variable containing the client ID. +- **`app-id`**: Numeric GitHub App ID, supported as a compatibility alias. +- **`app-id-env`**: Name of an environment variable containing the App ID. + +Configure exactly one identity source. When compatibility fields overlap, `client-id-env`, `client-id`, `app-id-env`, then `app-id` take precedence. + +### Private key + +Exactly one is required: + +- **`private-key-secret`**: Name of a Buildkite Secret. The hook calls `buildkite-agent secret get`. +- **`private-key-env`**: Name of an environment variable populated by Buildkite Secrets or another secret manager. + +Both normal multi-line PEM and PEM containing literal `\n` separators are accepted. Temporary key files are mode `0600` under a private umask and are removed immediately after signing. + +### Installation and repository scope + +- **`installation-id`**: Optional numeric installation ID. Skips installation discovery. +- **`installation-id-env`**: Environment-variable name containing an installation ID. +- **`owner`**: GitHub organization or user. When supplied without `repositories`, the token can access every repository granted to the installation. +- **`repositories`**: Repository names (maximum 500). Entries may be bare names or `owner/name`; all must have the configured owner. + +Without `owner` or `repositories`, the owner and repository are derived from `BUILDKITE_REPO`, including SSH and HTTPS GitHub Enterprise Server remotes. The token is narrowed to that repository. Supplying only `owner` intentionally requests the installation's full repository scope. + +### Token options + +- **`permissions`**: Map of GitHub permission names to `read` or `write`. Omitted permissions cannot exceed the App installation's grants. If omitted, GitHub uses the installation's granted permissions. +- **`github-api-url`**: HTTPS API root. Defaults to `https://api.github.com`; for GHES this is usually `https://github.example.com/api/v3`. +- **`output-variable`**: Exported variable name. Defaults to `GITHUB_TOKEN`. +- **`skip-revoke`**: Keep the token valid until GitHub expires it (normally one hour). Defaults to `false`. + +Example with an explicit installation and GitHub Enterprise Server: + +```yaml +plugins: + - buildkite-plugins/create-github-app-token#v1.0.0: + client-id-env: GITHUB_APP_CLIENT_ID + private-key-env: GITHUB_APP_PRIVATE_KEY + installation-id-env: GITHUB_APP_INSTALLATION_ID + github-api-url: https://github.example.com/api/v3 + owner: engineering + repositories: api,web + output-variable: GHES_TOKEN + permissions: + contents: read + pull_requests: write +``` + +## Security model + +- The App private key is a long-lived, high-value credential. Restrict its Buildkite Secret access policy to the required pipelines and steps, rotate it, and grant the App only necessary permissions. +- The installation token is passed to the command through an environment variable. Commands and processes in the job can read it. Do not run untrusted code in a step that has access to the token. +- The token is sent only to the configured HTTPS GitHub API URL. Proxy and custom CA behavior follows `curl` (`HTTPS_PROXY`, `NO_PROXY`, and the system trust store). +- The token is registered with `buildkite-agent redactor add` before export. Agent v3.67.0 or newer is required; creation fails closed if dynamic redaction is unavailable. Buildkite Secrets are independently redacted by Buildkite. +- Revocation is best-effort so a GitHub outage during `pre-exit` does not hide the command's result. A short-lived token may remain valid until expiry if revocation fails or the agent is terminated before `pre-exit`. +- On Agent Stack for Kubernetes, hooks and commands must share an environment and filesystem for the exported token and revocation state. Verify this lifecycle for your pod/container configuration. + +## Requirements + +Unix-like agent with Bash, Buildkite Agent, curl, jq, and OpenSSL. No language runtime or package installation is required. + +## Development + +```sh +docker compose run --rm tests +docker compose run --rm shellcheck +docker compose run --rm lint +``` + +The test suite uses Bats and exercises JWT creation, discovery, narrowing, redaction, revocation, failures, and cleanup. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bad6721 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + tests: + build: + context: . + dockerfile: Dockerfile.tests + volumes: + - .:/plugin:ro + shellcheck: + image: koalaman/shellcheck-alpine:stable + command: [hooks/pre-command, hooks/pre-exit, lib/plugin.bash] + working_dir: /plugin + volumes: + - .:/plugin:ro + lint: + image: buildkite/plugin-linter + command: [--id, buildkite-plugins/create-github-app-token] + volumes: + - .:/plugin:ro diff --git a/hooks/pre-command b/hooks/pre-command new file mode 100755 index 0000000..4bfdf00 --- /dev/null +++ b/hooks/pre-command @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=lib/plugin.bash +source "${PLUGIN_ROOT}/lib/plugin.bash" + +github_app_token_create diff --git a/hooks/pre-exit b/hooks/pre-exit new file mode 100755 index 0000000..11cf09d --- /dev/null +++ b/hooks/pre-exit @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=lib/plugin.bash +source "${PLUGIN_ROOT}/lib/plugin.bash" + +github_app_token_cleanup diff --git a/lib/plugin.bash b/lib/plugin.bash new file mode 100644 index 0000000..fe69411 --- /dev/null +++ b/lib/plugin.bash @@ -0,0 +1,375 @@ +#!/usr/bin/env bash + +PLUGIN_PREFIX="BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_" + +plugin_fail() { + printf 'create-github-app-token: %s\n' "$*" >&2 + return 1 +} + +plugin_config() { + local name="$1" default="${2-}" variable + variable="${PLUGIN_PREFIX}${name//-/_}" + variable="$(printf '%s' "$variable" | tr '[:lower:]' '[:upper:]')" + printf '%s' "${!variable:-$default}" +} + +plugin_indirect_value() { + local reference="$1" description="$2" + [[ "$reference" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { + plugin_fail "${description} must be a valid environment variable name" + return 1 + } + [[ -n "${!reference:-}" ]] || { + plugin_fail "environment variable ${reference} is empty or unset" + return 1 + } + printf '%s' "${!reference}" +} + +plugin_base64url() { + openssl base64 -A | tr '+/' '-_' | tr -d '=' +} + +plugin_json_error() { + local file="$1" fallback="$2" message + message="$(jq -r 'if type == "object" and (.message | type) == "string" then .message | gsub("[[:cntrl:]]"; " ") | .[:500] else empty end' "$file" 2>/dev/null || true)" + printf '%s' "${message:-$fallback}" +} + +plugin_api_request() { + local method="$1" path="$2" auth="$3" body_file="$4" expected="$5" payload="${6-}" + local status + local -a args=(--silent --show-error --connect-timeout 10 --max-time 60 + --request "$method" --output "$body_file" --write-out '%{http_code}' + --header 'Accept: application/vnd.github+json' + --header 'X-GitHub-Api-Version: 2022-11-28' + --header 'User-Agent: create-github-app-token-buildkite-plugin' + --header "Authorization: Bearer ${auth}") + if [[ -n "$payload" ]]; then + args+=(--header 'Content-Type: application/json' --data "$payload") + fi + + status="$(curl "${args[@]}" "${GITHUB_APP_API_URL}${path}")" || { + GITHUB_APP_HTTP_STATUS=unknown + GITHUB_APP_HTTP_MESSAGE='GitHub API request failed' + plugin_fail "GitHub API request failed for ${method} ${path}" + return 1 + } + if [[ "$status" != "$expected" ]]; then + GITHUB_APP_HTTP_STATUS="$status" + GITHUB_APP_HTTP_MESSAGE="$(plugin_json_error "$body_file" 'GitHub API request failed')" + return 1 + fi +} + +plugin_create_jwt() ( + local issuer="$1" key="$2" now header claims signing_input key_file signature + now="$(date +%s)" + header="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | plugin_base64url)" + claims="$(jq -cn --arg iss "$issuer" --argjson iat "$((now - 60))" --argjson exp "$((now + 540))" '{iat:$iat,exp:$exp,iss:$iss}' | plugin_base64url)" + signing_input="${header}.${claims}" + + key_file="$(mktemp "${TMPDIR:-/tmp}/github-app-key.XXXXXX")" + chmod 600 "$key_file" + trap 'rm -f "$key_file"' EXIT + trap 'exit 1' HUP INT TERM + key="${key//\\n/$'\n'}" + printf '%s\n' "$key" >"$key_file" + openssl pkey -in "$key_file" -noout >/dev/null 2>&1 || { + plugin_fail 'private key is not a valid PEM-encoded private key' + return 1 + } + signature="$(printf '%s' "$signing_input" | openssl dgst -sha256 -sign "$key_file" | plugin_base64url)" || { + plugin_fail 'failed to sign GitHub App JWT' + return 1 + } + printf '%s.%s' "$signing_input" "$signature" +) + +plugin_current_repository() { + local remote="${BUILDKITE_REPO:-}" path + [[ -n "$remote" ]] || plugin_fail 'owner/repositories are not configured and BUILDKITE_REPO is empty' + case "$remote" in + git@*:*) path="${remote#*:}" ;; + ssh://git@*/*) path="${remote#ssh://git@*/}" ;; + http://*/*|https://*/*) path="${remote#*://*/}" ;; + *) plugin_fail 'could not determine owner/repository from BUILDKITE_REPO' ; return 1 ;; + esac + path="${path%.git}" + [[ "$path" =~ ^[^/]+/[^/]+$ ]] || { + plugin_fail 'BUILDKITE_REPO must identify an owner/repository Git remote' + return 1 + } + printf '%s' "$path" +} + +plugin_collect_repositories() { + local scalar variable value + GITHUB_APP_CONFIGURED_REPOSITORIES=() + scalar="$(plugin_config repositories)" + if [[ -n "$scalar" ]]; then + while IFS= read -r value; do + [[ -n "$value" ]] && GITHUB_APP_CONFIGURED_REPOSITORIES+=("$value") + done < <(printf '%s\n' "$scalar" | tr ',' '\n') + fi + while IFS= read -r variable; do + GITHUB_APP_CONFIGURED_REPOSITORIES+=("${!variable}") + done < <(compgen -A variable "${PLUGIN_PREFIX}REPOSITORIES_" | sort -t_ -k8,8n) + ((${#GITHUB_APP_CONFIGURED_REPOSITORIES[@]} <= 500)) || plugin_fail 'repositories cannot contain more than 500 entries' +} + +plugin_target() { + local configured_owner="$1" current="" entry entry_owner entry_repo + local -a requested=() + plugin_collect_repositories || return + requested=("${GITHUB_APP_CONFIGURED_REPOSITORIES[@]}") + + if ((${#requested[@]} == 0)) && [[ -z "$configured_owner" ]]; then + current="$(plugin_current_repository)" || return + configured_owner="${current%%/*}" + requested=("${current#*/}") + fi + if ((${#requested[@]} > 0)) && [[ -z "$configured_owner" ]]; then + current="$(plugin_current_repository)" || return + configured_owner="${current%%/*}" + fi + + GITHUB_APP_OWNER="$configured_owner" + [[ "$GITHUB_APP_OWNER" =~ ^[A-Za-z0-9-]+$ ]] || { + plugin_fail 'owner must contain only letters, numbers, and hyphens' + return 1 + } + GITHUB_APP_REPOSITORIES=() + for entry in "${requested[@]}"; do + entry="${entry%.git}" + if [[ "$entry" == */* ]]; then + entry_owner="${entry%%/*}" + entry_repo="${entry#*/}" + [[ "$entry_owner" == "$configured_owner" ]] || { + plugin_fail "repository ${entry} does not belong to owner ${configured_owner}" + return 1 + } + else + entry_repo="$entry" + fi + [[ "$entry_repo" =~ ^[A-Za-z0-9._-]+$ ]] || { + plugin_fail "invalid repository name: ${entry_repo}" + return 1 + } + GITHUB_APP_REPOSITORIES+=("$entry_repo") + done +} + +plugin_permissions_json() { + local variable key value result='{}' + while IFS= read -r variable; do + key="${variable#"${PLUGIN_PREFIX}"PERMISSIONS_}" + key="$(printf '%s' "$key" | tr '[:upper:]' '[:lower:]')" + value="${!variable}" + [[ "$key" =~ ^[a-z][a-z0-9_]*$ ]] || { + plugin_fail "invalid permission name: ${key}" + return 1 + } + [[ "$value" == read || "$value" == write ]] || { + plugin_fail "permission ${key} must be read or write" + return 1 + } + result="$(jq -cn --argjson current "$result" --arg key "$key" --arg value "$value" '$current + {($key):$value}')" + done < <(compgen -A variable "${PLUGIN_PREFIX}PERMISSIONS_" | sort) + printf '%s' "$result" +} + +plugin_discover_installation() { + local jwt="$1" response="$2" path + if ((${#GITHUB_APP_REPOSITORIES[@]} > 0)); then + path="/repos/${GITHUB_APP_OWNER}/${GITHUB_APP_REPOSITORIES[0]}/installation" + plugin_api_request GET "$path" "$jwt" "$response" 200 || { + plugin_fail "could not find a GitHub App installation for ${GITHUB_APP_OWNER}/${GITHUB_APP_REPOSITORIES[0]} (HTTP ${GITHUB_APP_HTTP_STATUS}: ${GITHUB_APP_HTTP_MESSAGE})" + return 1 + } + else + path="/orgs/${GITHUB_APP_OWNER}/installation" + if ! plugin_api_request GET "$path" "$jwt" "$response" 200; then + path="/users/${GITHUB_APP_OWNER}/installation" + plugin_api_request GET "$path" "$jwt" "$response" 200 || { + plugin_fail "could not find a GitHub App installation for owner ${GITHUB_APP_OWNER} (HTTP ${GITHUB_APP_HTTP_STATUS}: ${GITHUB_APP_HTTP_MESSAGE})" + return 1 + } + fi + fi + jq -er '.id | select(type == "number" or type == "string")' "$response" 2>/dev/null || plugin_fail 'GitHub installation response did not contain an installation ID' +} + +github_app_token_create() { + local client_id client_id_env app_id_env private_key private_key_env private_key_secret + local installation_id installation_id_env output_variable skip_revoke jwt permissions payload token + local response state_dir + umask 077 + + client_id="$(plugin_config client-id)" + client_id_env="$(plugin_config client-id-env)" + app_id_env="$(plugin_config app-id-env)" + if [[ -n "$client_id_env" ]]; then + client_id="$(plugin_indirect_value "$client_id_env" client-id-env)" || return + fi + if [[ -z "$client_id" && -n "$app_id_env" ]]; then + client_id="$(plugin_indirect_value "$app_id_env" app-id-env)" || return + fi + [[ -n "$client_id" ]] || client_id="$(plugin_config app-id)" + [[ "$client_id" =~ ^(Iv1\.[A-Fa-f0-9]+|[0-9]+)$ ]] || { + plugin_fail 'client-id/app-id is missing or malformed' + return 1 + } + + private_key_env="$(plugin_config private-key-env)" + private_key_secret="$(plugin_config private-key-secret)" + if [[ -n "$private_key_secret" && -n "$private_key_env" ]]; then + plugin_fail 'configure exactly one of private-key-secret or private-key-env' + return 1 + elif [[ -n "$private_key_secret" ]]; then + private_key="$(buildkite-agent secret get "$private_key_secret")" || { + plugin_fail "could not retrieve Buildkite Secret ${private_key_secret}" + return 1 + } + elif [[ -n "$private_key_env" ]]; then + private_key="$(plugin_indirect_value "$private_key_env" private-key-env)" || return + else + plugin_fail 'private-key-secret or private-key-env is required' + return 1 + fi + + GITHUB_APP_API_URL="$(plugin_config github-api-url 'https://api.github.com')" + GITHUB_APP_API_URL="${GITHUB_APP_API_URL%/}" + [[ "$GITHUB_APP_API_URL" =~ ^https://[^[:space:]?#]+$ ]] || { + plugin_fail 'github-api-url must be a valid HTTPS API root without a query or fragment' + return 1 + } + output_variable="$(plugin_config output-variable GITHUB_TOKEN)" + [[ "$output_variable" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { + plugin_fail 'output-variable must be a valid environment variable name' + return 1 + } + skip_revoke="$(plugin_config skip-revoke false)" + [[ "$skip_revoke" == true || "$skip_revoke" == false ]] || { + plugin_fail 'skip-revoke must be true or false' + return 1 + } + + plugin_target "$(plugin_config owner)" || return + jwt="$(plugin_create_jwt "$client_id" "$private_key")" || return + unset private_key + response="$(mktemp "${TMPDIR:-/tmp}/github-app-response.XXXXXX")" + + installation_id="$(plugin_config installation-id)" + installation_id_env="$(plugin_config installation-id-env)" + if [[ -n "$installation_id_env" ]]; then + installation_id="$(plugin_indirect_value "$installation_id_env" installation-id-env)" || { + rm -f "$response" + return 1 + } + fi + if [[ -z "$installation_id" ]]; then + installation_id="$(plugin_discover_installation "$jwt" "$response")" || { + rm -f "$response" + return 1 + } + fi + [[ "$installation_id" =~ ^[0-9]+$ ]] || { + rm -f "$response" + plugin_fail 'installation-id is malformed' + return 1 + } + + permissions="$(plugin_permissions_json)" || { + rm -f "$response" + return 1 + } + payload='{}' + if [[ "$permissions" != '{}' ]]; then + payload="$(jq -cn --argjson permissions "$permissions" '{permissions:$permissions}')" + fi + if ((${#GITHUB_APP_REPOSITORIES[@]} > 0)); then + payload="$(jq -cn --argjson current "$payload" --args '$current + {repositories:$ARGS.positional}' -- "${GITHUB_APP_REPOSITORIES[@]}")" + fi + plugin_api_request POST "/app/installations/${installation_id}/access_tokens" "$jwt" "$response" 201 "$payload" || { + local api_status="$GITHUB_APP_HTTP_STATUS" api_message="$GITHUB_APP_HTTP_MESSAGE" + rm -f "$response" + plugin_fail "could not create installation token (HTTP ${api_status}: ${api_message})" + return 1 + } + token="$(jq -er '.token | select(type == "string" and length > 0)' "$response" 2>/dev/null)" || { + rm -f "$response" + plugin_fail 'GitHub token response did not contain a token' + return 1 + } + + buildkite-agent redactor add "$token" >/dev/null || { + plugin_api_request DELETE '/installation/token' "$token" "$response" 204 || true + rm -f "$response" + unset token + plugin_fail 'could not register the installation token with the Buildkite redactor (agent v3.67.0 or newer is required)' + return 1 + } + + if [[ "$skip_revoke" == false ]]; then + state_dir="$(mktemp -d "${TMPDIR:-/tmp}/github-app-token-state.XXXXXX")" || { + plugin_api_request DELETE '/installation/token' "$token" "$response" 204 || true + rm -f "$response" + unset token + plugin_fail 'could not create private token state directory' + return 1 + } + printf '%s' "$token" >"${state_dir}/token" + printf '%s' "$GITHUB_APP_API_URL" >"${state_dir}/api-url" + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR="$state_dir" + fi + printf -v "$output_variable" '%s' "$token" + export "${output_variable?}" + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME="$output_variable" + unset token jwt + rm -f "$response" + printf '%s\n' 'GitHub App installation token created and registered for redaction' +} + +github_app_token_cleanup() { + local state_dir="${BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR:-}" token response status + local output_variable="${BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME:-}" + if [[ -z "$state_dir" ]]; then + [[ -n "$output_variable" ]] && unset "$output_variable" + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME + return 0 + fi + case "$state_dir" in + "${TMPDIR:-/tmp}"/github-app-token-state.*) ;; + *) + printf '%s\n' 'create-github-app-token: token state path is invalid; revocation was not attempted' >&2 + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR + [[ -n "$output_variable" ]] && unset "$output_variable" + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME + return 0 + ;; + esac + if [[ ! -d "$state_dir" || ! -f "$state_dir/token" || ! -f "$state_dir/api-url" ]]; then + printf '%s\n' 'create-github-app-token: token state is missing; revocation was not attempted' >&2 + rm -rf "$state_dir" + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR + [[ -n "$output_variable" ]] && unset "$output_variable" + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME + return 0 + fi + token="$(cat "${state_dir}/token")" + GITHUB_APP_API_URL="$(cat "${state_dir}/api-url")" + response="${state_dir}/response" + if ! plugin_api_request DELETE '/installation/token' "$token" "$response" 204; then + status="${GITHUB_APP_HTTP_STATUS:-unknown}" + printf 'create-github-app-token: warning: token revocation failed (HTTP %s)\n' "$status" >&2 + else + printf '%s\n' 'GitHub App installation token revoked' + fi + unset token + rm -rf "$state_dir" + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR + [[ -n "$output_variable" ]] && unset "$output_variable" + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME +} diff --git a/plugin.yml b/plugin.yml new file mode 100644 index 0000000..24268cd --- /dev/null +++ b/plugin.yml @@ -0,0 +1,76 @@ +name: Create GitHub App Token +description: Creates a short-lived, least-privilege GitHub App installation access token +author: https://github.com/buildkite +requirements: + - bash + - buildkite-agent + - curl + - jq + - openssl +configuration: + properties: + client-id: + type: string + description: GitHub App client ID (preferred JWT issuer) + client-id-env: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + description: Environment variable containing the GitHub App client ID + app-id: + type: string + description: GitHub App ID (compatibility alias for client-id) + app-id-env: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + description: Environment variable containing the GitHub App ID + private-key-env: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + description: Environment variable containing the GitHub App private key + private-key-secret: + type: string + pattern: "^[A-Za-z0-9_]+$" + description: Buildkite Secret key containing the GitHub App private key + owner: + type: string + pattern: "^[A-Za-z0-9-]+$" + repositories: + oneOf: + - type: string + - type: array + items: + type: string + minLength: 1 + maxItems: 500 + installation-id: + type: string + pattern: "^[0-9]+$" + installation-id-env: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + github-api-url: + type: string + pattern: "^https://[^?#\\s]+/?$" + default: https://api.github.com + output-variable: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + default: GITHUB_TOKEN + permissions: + type: object + description: GitHub installation token permissions, such as contents or pull_requests + additionalProperties: + enum: [read, write] + skip-revoke: + type: boolean + default: false + allOf: + - anyOf: + - required: [client-id] + - required: [client-id-env] + - required: [app-id] + - required: [app-id-env] + - oneOf: + - required: [private-key-env] + - required: [private-key-secret] + additionalProperties: false diff --git a/tests/plugin.bats b/tests/plugin.bats new file mode 100755 index 0000000..52bfb12 --- /dev/null +++ b/tests/plugin.bats @@ -0,0 +1,265 @@ +#!/usr/bin/env bats + +setup() { + export TEST_TMPDIR="$(mktemp -d)" + export TMPDIR="$TEST_TMPDIR/tmp" + export MOCK_LOG="$TEST_TMPDIR/requests.log" + mkdir -p "$TMPDIR" "$TEST_TMPDIR/bin" + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$TEST_TMPDIR/key.pem" 2>/dev/null + export TEST_PRIVATE_KEY="$(cat "$TEST_TMPDIR/key.pem")" + export PATH="$TEST_TMPDIR/bin:$PATH" + export BUILDKITE_REPO="git@github.com:acme/widgets.git" + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_CLIENT_ID="Iv1.0123456789abcdef" + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PRIVATE_KEY_ENV="TEST_PRIVATE_KEY" + unset GITHUB_TOKEN ACME_GITHUB_TOKEN + unset_plugin_options + create_mocks +} + +teardown() { + rm -rf "$TEST_TMPDIR" +} + +unset_plugin_options() { + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_APP_ID + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_APP_ID_ENV + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_CLIENT_ID_ENV + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_GITHUB_API_URL + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_INSTALLATION_ID + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_INSTALLATION_ID_ENV + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OWNER + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_CONTENTS + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_PULL_REQUESTS + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PRIVATE_KEY_SECRET + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES_0 + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES_1 + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_SKIP_REVOKE + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME +} + +create_mocks() { + cat >"$TEST_TMPDIR/bin/buildkite-agent" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1 $2" == "secret get" ]]; then + printf '%s' "$TEST_PRIVATE_KEY" +elif [[ "$1 $2" == "redactor add" ]]; then + printf 'redactor:%s\n' "$3" >>"$MOCK_LOG" +else + exit 2 +fi +MOCK + cat >"$TEST_TMPDIR/bin/curl" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +method=GET output= payload= url= +while (($#)); do + case "$1" in + --request) method="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + --data) payload="$2"; shift 2 ;; + --header|--connect-timeout|--max-time|--write-out) shift 2 ;; + --silent|--show-error) shift ;; + *) url="$1"; shift ;; + esac +done +printf '%s %s %s\n' "$method" "$url" "$payload" >>"$MOCK_LOG" +if [[ "${MOCK_CURL_FAIL:-false}" == true ]]; then exit 7; fi +if [[ "$method" == DELETE ]]; then + : >"$output" + printf '%s' "${MOCK_REVOKE_STATUS:-204}" +elif [[ "$url" == */access_tokens ]]; then + if [[ "${MOCK_TOKEN_STATUS:-201}" == 201 ]]; then printf '{"token":"ghs_test_token"}' >"$output"; else printf '{"message":"Bad credentials","secret":"must-not-leak"}' >"$output"; fi + printf '%s' "${MOCK_TOKEN_STATUS:-201}" +elif [[ "$url" == */installation ]]; then + if [[ "${MOCK_DISCOVERY_STATUS:-200}" == 200 ]]; then printf '{"id":42}' >"$output"; else printf '{"message":"Not Found","private":"must-not-leak"}' >"$output"; fi + printf '%s' "${MOCK_DISCOVERY_STATUS:-200}" +else + exit 3 +fi +MOCK + chmod +x "$TEST_TMPDIR/bin/buildkite-agent" "$TEST_TMPDIR/bin/curl" +} + +create_token() { + source "$BATS_TEST_DIRNAME/../lib/plugin.bash" + github_app_token_create +} + +@test "discovers installation and narrows default token to current repository" { + create_token + [[ "$GITHUB_TOKEN" == ghs_test_token ]] + grep -F 'GET https://api.github.com/repos/acme/widgets/installation' "$MOCK_LOG" + payload="$(awk '/access_tokens/{sub(/^.*access_tokens /, ""); print}' "$MOCK_LOG")" + [[ "$(jq -r '.repositories[0]' <<<"$payload")" == widgets ]] + [[ "$(jq 'has("permissions")' <<<"$payload")" == false ]] +} + +@test "uses an explicit installation ID without discovery" { + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_INSTALLATION_ID=99 + create_token + grep -F 'POST https://api.github.com/app/installations/99/access_tokens' "$MOCK_LOG" + ! grep -F '/installation ' "$MOCK_LOG" +} + +@test "sends repository and permission narrowing payload" { + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OWNER=acme + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES_0=api + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES_1=web + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_CONTENTS=read + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_PULL_REQUESTS=write + create_token + payload="$(awk '/access_tokens/{sub(/^.*access_tokens /, ""); print}' "$MOCK_LOG")" + jq -e '.repositories == ["api","web"] and .permissions == {contents:"read",pull_requests:"write"}' <<<"$payload" +} + +@test "exports a custom output variable and registers redaction first" { + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE=ACME_GITHUB_TOKEN + create_token + [[ "$ACME_GITHUB_TOKEN" == ghs_test_token ]] + [[ "$BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME" == ACME_GITHUB_TOKEN ]] + grep -Fx 'redactor:ghs_test_token' "$MOCK_LOG" + [[ "$(tail -1 "$MOCK_LOG")" == redactor:* ]] +} + +@test "retrieves a private key from Buildkite Secrets" { + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PRIVATE_KEY_ENV + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PRIVATE_KEY_SECRET=GITHUB_APP_PRIVATE_KEY + create_token + [[ "$GITHUB_TOKEN" == ghs_test_token ]] +} + +@test "accepts escaped PEM newlines" { + export TEST_PRIVATE_KEY="${TEST_PRIVATE_KEY//$'\n'/\\n}" + create_token + [[ "$GITHUB_TOKEN" == ghs_test_token ]] +} + +@test "uses custom GHES API URL and parses its HTTPS repository remote" { + export BUILDKITE_REPO=https://github.example.com/acme/enterprise.git + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_GITHUB_API_URL=https://github.example.com/api/v3/ + create_token + grep -F 'GET https://github.example.com/api/v3/repos/acme/enterprise/installation' "$MOCK_LOG" +} + +@test "revokes token and deletes private state" { + create_token + state="$BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR" + [[ -f "$state/token" ]] + [[ "$(stat -c '%a' "$state/token")" == 600 ]] + github_app_token_cleanup + grep -F 'DELETE https://api.github.com/installation/token' "$MOCK_LOG" + [[ ! -e "$state" ]] + [[ -z "${GITHUB_TOKEN:-}" ]] + [[ -z "${BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME:-}" ]] +} + +@test "skip-revoke creates no token state" { + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_SKIP_REVOKE=true + create_token + [[ -z "${BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR:-}" ]] + github_app_token_cleanup + [[ -z "${GITHUB_TOKEN:-}" ]] + ! grep -F 'DELETE ' "$MOCK_LOG" +} + +@test "revocation failure is non-fatal and still removes state" { + export MOCK_REVOKE_STATUS=500 + create_token + state="$BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR" + run github_app_token_cleanup + [[ "$status" -eq 0 ]] + [[ "$output" == *'warning: token revocation failed (HTTP 500)'* ]] + [[ ! -e "$state" ]] +} + +@test "reports sanitized installation discovery failures" { + export MOCK_DISCOVERY_STATUS=404 + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'HTTP 404: Not Found'* ]] + [[ "$output" != *'must-not-leak'* ]] +} + +@test "reports sanitized token API failures" { + export MOCK_TOKEN_STATUS=401 + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'HTTP 401: Bad credentials'* ]] + [[ "$output" != *'must-not-leak'* ]] +} + +@test "reports network failures without leaking authorization material" { + export MOCK_CURL_FAIL=true + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'GitHub API request failed'* ]] + [[ "$output" != *'Bearer'* ]] +} + +@test "fails closed when dynamic redaction is unavailable" { + cat >"$TEST_TMPDIR/bin/buildkite-agent" <<'MOCK' +#!/usr/bin/env bash +[[ "$1 $2" == "redactor add" ]] && exit 1 +exit 2 +MOCK + chmod +x "$TEST_TMPDIR/bin/buildkite-agent" + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'could not register the installation token'* ]] + [[ -z "${GITHUB_TOKEN:-}" ]] + [[ -z "$(find "$TMPDIR" -type f -print -quit)" ]] + grep -F 'DELETE https://api.github.com/installation/token' "$MOCK_LOG" +} + +@test "rejects malformed private keys and removes temporary key files" { + export TEST_PRIVATE_KEY='not a key' + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'not a valid PEM-encoded private key'* ]] + [[ -z "$(find "$TMPDIR" -type f -print -quit)" ]] +} + +@test "rejects malformed output variables" { + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE='BAD-NAME' + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'output-variable must be a valid'* ]] +} + +@test "rejects repository owner mismatches" { + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OWNER=acme + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES='other/repo' + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'does not belong to owner acme'* ]] +} + +@test "JWT has skew-safe claims and a valid RSA signature" { + source "$BATS_TEST_DIRNAME/../lib/plugin.bash" + before="$(date +%s)" + jwt="$(plugin_create_jwt Iv1.0123456789abcdef "$TEST_PRIVATE_KEY")" + after="$(date +%s)" + claims="$(cut -d. -f2 <<<"$jwt" | tr '_-' '/+')" + padding=$(( (4 - ${#claims} % 4) % 4 )) + claims="${claims}$(printf '%*s' "$padding" '' | tr ' ' '=')" + claims="$(printf '%s' "$claims" | base64 -d 2>/dev/null)" + [[ "$(jq -r .iss <<<"$claims")" == Iv1.0123456789abcdef ]] + iat="$(jq -r .iat <<<"$claims")" + exp="$(jq -r .exp <<<"$claims")" + ((iat <= before - 60 && iat >= before - 61)) + ((exp >= after + 539 && exp <= after + 540)) + + signing_input="${jwt%.*}" + signature="${jwt##*.}" + padding=$(( (4 - ${#signature} % 4) % 4 )) + signature="${signature//_/\/}" + signature="${signature//-/+}" + printf '%s' "$signature" >"$TEST_TMPDIR/signature.b64" + printf '%*s' "$padding" '' | tr ' ' '=' >>"$TEST_TMPDIR/signature.b64" + base64 -d "$TEST_TMPDIR/signature.b64" >"$TEST_TMPDIR/signature.bin" + openssl pkey -in "$TEST_TMPDIR/key.pem" -pubout -out "$TEST_TMPDIR/public.pem" 2>/dev/null + printf '%s' "$signing_input" | openssl dgst -sha256 -verify "$TEST_TMPDIR/public.pem" -signature "$TEST_TMPDIR/signature.bin" +} From 2fbb574c13ab6e1cb0191cf9ba5cdde3acd5489c Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 24 Aug 2026 06:31:07 +0000 Subject: [PATCH 2/6] Harden token requests and compatibility Amp-Thread-ID: https://ampcode.com/threads/T-01a0277c-8ac1-77fc-91b8-f1fc859fe02f Co-authored-by: Lachlan Donald --- README.md | 2 +- lib/plugin.bash | 25 ++++++++++++++++--------- plugin.yml | 2 +- tests/plugin.bats | 29 ++++++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 23576a4..46ad7e6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Without `owner` or `repositories`, the owner and repository are derived from `BU ### Token options -- **`permissions`**: Map of GitHub permission names to `read` or `write`. Omitted permissions cannot exceed the App installation's grants. If omitted, GitHub uses the installation's granted permissions. +- **`permissions`**: Map of GitHub permission names to `read`, `write`, or `admin` where GitHub supports that level. Omitted permissions cannot exceed the App installation's grants. If omitted, GitHub uses the installation's granted permissions. - **`github-api-url`**: HTTPS API root. Defaults to `https://api.github.com`; for GHES this is usually `https://github.example.com/api/v3`. - **`output-variable`**: Exported variable name. Defaults to `GITHUB_TOKEN`. - **`skip-revoke`**: Keep the token valid until GitHub expires it (normally one hour). Defaults to `false`. diff --git a/lib/plugin.bash b/lib/plugin.bash index fe69411..c92b821 100644 --- a/lib/plugin.bash +++ b/lib/plugin.bash @@ -44,13 +44,12 @@ plugin_api_request() { --request "$method" --output "$body_file" --write-out '%{http_code}' --header 'Accept: application/vnd.github+json' --header 'X-GitHub-Api-Version: 2022-11-28' - --header 'User-Agent: create-github-app-token-buildkite-plugin' - --header "Authorization: Bearer ${auth}") + --header 'User-Agent: create-github-app-token-buildkite-plugin') if [[ -n "$payload" ]]; then args+=(--header 'Content-Type: application/json' --data "$payload") fi - status="$(curl "${args[@]}" "${GITHUB_APP_API_URL}${path}")" || { + status="$(printf 'header = "Authorization: Bearer %s"\n' "$auth" | curl --config - "${args[@]}" "${GITHUB_APP_API_URL}${path}")" || { GITHUB_APP_HTTP_STATUS=unknown GITHUB_APP_HTTP_MESSAGE='GitHub API request failed' plugin_fail "GitHub API request failed for ${method} ${path}" @@ -131,8 +130,12 @@ plugin_target() { requested=("${current#*/}") fi if ((${#requested[@]} > 0)) && [[ -z "$configured_owner" ]]; then - current="$(plugin_current_repository)" || return - configured_owner="${current%%/*}" + if [[ "${requested[0]}" == */* ]]; then + configured_owner="${requested[0]%%/*}" + else + current="$(plugin_current_repository)" || return + configured_owner="${current%%/*}" + fi fi GITHUB_APP_OWNER="$configured_owner" @@ -171,8 +174,8 @@ plugin_permissions_json() { plugin_fail "invalid permission name: ${key}" return 1 } - [[ "$value" == read || "$value" == write ]] || { - plugin_fail "permission ${key} must be read or write" + [[ "$value" == read || "$value" == write || "$value" == admin ]] || { + plugin_fail "permission ${key} must be read, write, or admin" return 1 } result="$(jq -cn --argjson current "$result" --arg key "$key" --arg value "$value" '$current + {($key):$value}')" @@ -191,6 +194,10 @@ plugin_discover_installation() { else path="/orgs/${GITHUB_APP_OWNER}/installation" if ! plugin_api_request GET "$path" "$jwt" "$response" 200; then + if [[ "$GITHUB_APP_HTTP_STATUS" != 404 ]]; then + plugin_fail "could not find a GitHub App installation for owner ${GITHUB_APP_OWNER} (HTTP ${GITHUB_APP_HTTP_STATUS}: ${GITHUB_APP_HTTP_MESSAGE})" + return 1 + fi path="/users/${GITHUB_APP_OWNER}/installation" plugin_api_request GET "$path" "$jwt" "$response" 200 || { plugin_fail "could not find a GitHub App installation for owner ${GITHUB_APP_OWNER} (HTTP ${GITHUB_APP_HTTP_STATUS}: ${GITHUB_APP_HTTP_MESSAGE})" @@ -217,8 +224,8 @@ github_app_token_create() { client_id="$(plugin_indirect_value "$app_id_env" app-id-env)" || return fi [[ -n "$client_id" ]] || client_id="$(plugin_config app-id)" - [[ "$client_id" =~ ^(Iv1\.[A-Fa-f0-9]+|[0-9]+)$ ]] || { - plugin_fail 'client-id/app-id is missing or malformed' + [[ -n "$client_id" ]] || { + plugin_fail 'client-id or app-id is required' return 1 } diff --git a/plugin.yml b/plugin.yml index 24268cd..3665f05 100644 --- a/plugin.yml +++ b/plugin.yml @@ -60,7 +60,7 @@ configuration: type: object description: GitHub installation token permissions, such as contents or pull_requests additionalProperties: - enum: [read, write] + enum: [read, write, admin] skip-revoke: type: boolean default: false diff --git a/tests/plugin.bats b/tests/plugin.bats index 52bfb12..e1668c9 100755 --- a/tests/plugin.bats +++ b/tests/plugin.bats @@ -31,6 +31,7 @@ unset_plugin_options() { unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OWNER unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_CONTENTS unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_PULL_REQUESTS + unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_REPOSITORY_PROJECTS unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PRIVATE_KEY_SECRET unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES_0 @@ -56,7 +57,9 @@ MOCK set -euo pipefail method=GET output= payload= url= while (($#)); do + [[ "$1" != *Bearer* ]] || exit 9 case "$1" in + --config) shift 2 ;; --request) method="$2"; shift 2 ;; --output) output="$2"; shift 2 ;; --data) payload="$2"; shift 2 ;; @@ -110,9 +113,23 @@ create_token() { export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES_1=web export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_CONTENTS=read export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_PULL_REQUESTS=write + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PERMISSIONS_REPOSITORY_PROJECTS=admin create_token payload="$(awk '/access_tokens/{sub(/^.*access_tokens /, ""); print}' "$MOCK_LOG")" - jq -e '.repositories == ["api","web"] and .permissions == {contents:"read",pull_requests:"write"}' <<<"$payload" + jq -e '.repositories == ["api","web"] and .permissions == {contents:"read",pull_requests:"write",repository_projects:"admin"}' <<<"$payload" +} + +@test "derives owner from a qualified repository without BUILDKITE_REPO" { + unset BUILDKITE_REPO + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES='acme/api' + create_token + grep -F 'GET https://api.github.com/repos/acme/api/installation' "$MOCK_LOG" +} + +@test "accepts modern opaque GitHub App client IDs" { + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_CLIENT_ID=Iv23f8doAlphaNumer1c + create_token + [[ "$GITHUB_TOKEN" == ghs_test_token ]] } @test "exports a custom output variable and registers redaction first" { @@ -183,6 +200,16 @@ create_token() { [[ "$output" != *'must-not-leak'* ]] } +@test "does not retry user discovery after an organization API failure" { + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OWNER=acme + export MOCK_DISCOVERY_STATUS=500 + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'HTTP 500: Not Found'* ]] + [[ "$(grep -c '/installation' "$MOCK_LOG")" -eq 1 ]] + grep -F '/orgs/acme/installation' "$MOCK_LOG" +} + @test "reports sanitized token API failures" { export MOCK_TOKEN_STATUS=401 run create_token From fa103d6888bc56e9d40cef6d5ed89e904528441d Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 24 Aug 2026 07:30:44 +0000 Subject: [PATCH 3/6] Fix redactor input and fail-closed cleanup Amp-Thread-ID: https://ampcode.com/threads/T-01a0277c-8ac1-77fc-91b8-f1fc859fe02f Co-authored-by: Lachlan Donald --- lib/plugin.bash | 73 ++++++++++++++++++++++++++++++++++---------- tests/plugin.bats | 77 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 18 deletions(-) diff --git a/lib/plugin.bash b/lib/plugin.bash index c92b821..f68b762 100644 --- a/lib/plugin.bash +++ b/lib/plugin.bash @@ -14,6 +14,18 @@ plugin_config() { printf '%s' "${!variable:-$default}" } +plugin_config_variables() { + local prefix="$1" variables sorted + shift + variables="$(compgen -A variable "$prefix" || true)" + [[ -n "$variables" ]] || return 0 + sorted="$(sort "$@" <<<"$variables")" || { + plugin_fail 'failed to sort plugin configuration variables' + return 1 + } + printf '%s' "$sorted" +} + plugin_indirect_value() { local reference="$1" description="$2" [[ "$reference" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { @@ -64,9 +76,19 @@ plugin_api_request() { plugin_create_jwt() ( local issuer="$1" key="$2" now header claims signing_input key_file signature - now="$(date +%s)" - header="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | plugin_base64url)" - claims="$(jq -cn --arg iss "$issuer" --argjson iat "$((now - 60))" --argjson exp "$((now + 540))" '{iat:$iat,exp:$exp,iss:$iss}' | plugin_base64url)" + set -o pipefail + now="$(date +%s)" || { + plugin_fail 'failed to read system time' + return 1 + } + header="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | plugin_base64url)" || { + plugin_fail 'failed to encode JWT header' + return 1 + } + claims="$(jq -cn --arg iss "$issuer" --argjson iat "$((now - 60))" --argjson exp "$((now + 540))" '{iat:$iat,exp:$exp,iss:$iss}' | plugin_base64url)" || { + plugin_fail 'failed to encode JWT claims' + return 1 + } signing_input="${header}.${claims}" key_file="$(mktemp "${TMPDIR:-/tmp}/github-app-key.XXXXXX")" @@ -104,17 +126,20 @@ plugin_current_repository() { } plugin_collect_repositories() { - local scalar variable value + local scalar variable variables value GITHUB_APP_CONFIGURED_REPOSITORIES=() scalar="$(plugin_config repositories)" if [[ -n "$scalar" ]]; then + scalar="${scalar//,/$'\n'}" while IFS= read -r value; do [[ -n "$value" ]] && GITHUB_APP_CONFIGURED_REPOSITORIES+=("$value") - done < <(printf '%s\n' "$scalar" | tr ',' '\n') + done <<<"$scalar" fi + variables="$(plugin_config_variables "${PLUGIN_PREFIX}REPOSITORIES_" -t_ -k8,8n)" || return while IFS= read -r variable; do + [[ -n "$variable" ]] || continue GITHUB_APP_CONFIGURED_REPOSITORIES+=("${!variable}") - done < <(compgen -A variable "${PLUGIN_PREFIX}REPOSITORIES_" | sort -t_ -k8,8n) + done <<<"$variables" ((${#GITHUB_APP_CONFIGURED_REPOSITORIES[@]} <= 500)) || plugin_fail 'repositories cannot contain more than 500 entries' } @@ -165,8 +190,10 @@ plugin_target() { } plugin_permissions_json() { - local variable key value result='{}' + local variable variables key value result='{}' + variables="$(plugin_config_variables "${PLUGIN_PREFIX}PERMISSIONS_")" || return while IFS= read -r variable; do + [[ -n "$variable" ]] || continue key="${variable#"${PLUGIN_PREFIX}"PERMISSIONS_}" key="$(printf '%s' "$key" | tr '[:upper:]' '[:lower:]')" value="${!variable}" @@ -179,7 +206,7 @@ plugin_permissions_json() { return 1 } result="$(jq -cn --argjson current "$result" --arg key "$key" --arg value "$value" '$current + {($key):$value}')" - done < <(compgen -A variable "${PLUGIN_PREFIX}PERMISSIONS_" | sort) + done <<<"$variables" printf '%s' "$result" } @@ -311,7 +338,7 @@ github_app_token_create() { return 1 } - buildkite-agent redactor add "$token" >/dev/null || { + printf '%s' "$token" | buildkite-agent redactor add >/dev/null || { plugin_api_request DELETE '/installation/token' "$token" "$response" 204 || true rm -f "$response" unset token @@ -327,8 +354,14 @@ github_app_token_create() { plugin_fail 'could not create private token state directory' return 1 } - printf '%s' "$token" >"${state_dir}/token" - printf '%s' "$GITHUB_APP_API_URL" >"${state_dir}/api-url" + if ! printf '%s' "$token" >"${state_dir}/token" || ! printf '%s' "$GITHUB_APP_API_URL" >"${state_dir}/api-url"; then + plugin_api_request DELETE '/installation/token' "$token" "$response" 204 || true + rm -f "$response" + rm -rf "$state_dir" || true + unset token + plugin_fail 'could not write private token state' + return 1 + fi export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR="$state_dir" fi printf -v "$output_variable" '%s' "$token" @@ -359,23 +392,31 @@ github_app_token_cleanup() { esac if [[ ! -d "$state_dir" || ! -f "$state_dir/token" || ! -f "$state_dir/api-url" ]]; then printf '%s\n' 'create-github-app-token: token state is missing; revocation was not attempted' >&2 - rm -rf "$state_dir" + rm -rf "$state_dir" || printf '%s\n' 'create-github-app-token: warning: failed to remove token state' >&2 unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR [[ -n "$output_variable" ]] && unset "$output_variable" unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME return 0 fi - token="$(cat "${state_dir}/token")" - GITHUB_APP_API_URL="$(cat "${state_dir}/api-url")" + token="$(cat "${state_dir}/token")" || { + printf '%s\n' 'create-github-app-token: warning: failed to read token state; revocation was not attempted' >&2 + token='' + } + GITHUB_APP_API_URL="$(cat "${state_dir}/api-url")" || { + printf '%s\n' 'create-github-app-token: warning: failed to read API URL state; revocation was not attempted' >&2 + GITHUB_APP_API_URL='' + } response="${state_dir}/response" - if ! plugin_api_request DELETE '/installation/token' "$token" "$response" 204; then + if [[ -z "$token" || -z "$GITHUB_APP_API_URL" ]]; then + : + elif ! plugin_api_request DELETE '/installation/token' "$token" "$response" 204; then status="${GITHUB_APP_HTTP_STATUS:-unknown}" printf 'create-github-app-token: warning: token revocation failed (HTTP %s)\n' "$status" >&2 else printf '%s\n' 'GitHub App installation token revoked' fi unset token - rm -rf "$state_dir" + rm -rf "$state_dir" || printf '%s\n' 'create-github-app-token: warning: failed to remove token state' >&2 unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR [[ -n "$output_variable" ]] && unset "$output_variable" unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_OUTPUT_VARIABLE_NAME diff --git a/tests/plugin.bats b/tests/plugin.bats index e1668c9..0cec58f 100755 --- a/tests/plugin.bats +++ b/tests/plugin.bats @@ -7,6 +7,7 @@ setup() { mkdir -p "$TMPDIR" "$TEST_TMPDIR/bin" openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$TEST_TMPDIR/key.pem" 2>/dev/null export TEST_PRIVATE_KEY="$(cat "$TEST_TMPDIR/key.pem")" + export TEST_REAL_MKTEMP="$(command -v mktemp)" export PATH="$TEST_TMPDIR/bin:$PATH" export BUILDKITE_REPO="git@github.com:acme/widgets.git" export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_CLIENT_ID="Iv1.0123456789abcdef" @@ -45,9 +46,12 @@ create_mocks() { #!/usr/bin/env bash set -euo pipefail if [[ "$1 $2" == "secret get" ]]; then + [[ "$#" -eq 3 && "$3" == GITHUB_APP_PRIVATE_KEY ]] printf '%s' "$TEST_PRIVATE_KEY" elif [[ "$1 $2" == "redactor add" ]]; then - printf 'redactor:%s\n' "$3" >>"$MOCK_LOG" + [[ "$#" -eq 2 ]] + token="$(cat)" + printf 'redactor:%s\n' "$token" >>"$MOCK_LOG" else exit 2 fi @@ -56,10 +60,15 @@ MOCK #!/usr/bin/env bash set -euo pipefail method=GET output= payload= url= +config= auth= while (($#)); do [[ "$1" != *Bearer* ]] || exit 9 case "$1" in - --config) shift 2 ;; + --config) + [[ "$2" == - && -z "$config" ]] + config="$(cat)" + shift 2 + ;; --request) method="$2"; shift 2 ;; --output) output="$2"; shift 2 ;; --data) payload="$2"; shift 2 ;; @@ -68,6 +77,13 @@ while (($#)); do *) url="$1"; shift ;; esac done +[[ "$config" =~ ^header\ =\ \"Authorization:\ Bearer\ ([^\"]+)\"$ ]] +auth="${BASH_REMATCH[1]}" +if [[ "$method" == DELETE ]]; then + [[ "$auth" == ghs_test_token ]] +else + [[ "$auth" == *.*.* ]] +fi printf '%s %s %s\n' "$method" "$url" "$payload" >>"$MOCK_LOG" if [[ "${MOCK_CURL_FAIL:-false}" == true ]]; then exit 7; fi if [[ "$method" == DELETE ]]; then @@ -192,6 +208,16 @@ create_token() { [[ ! -e "$state" ]] } +@test "unreadable state does not make cleanup fatal" { + create_token + state="$BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR" + chmod 000 "$state/token" + run github_app_token_cleanup + [[ "$status" -eq 0 ]] + [[ "$output" == *'failed to read token state; revocation was not attempted'* ]] + [[ ! -e "$state" ]] +} + @test "reports sanitized installation discovery failures" { export MOCK_DISCOVERY_STATUS=404 run create_token @@ -210,6 +236,53 @@ create_token() { grep -F '/orgs/acme/installation' "$MOCK_LOG" } +@test "fails closed when plugin configuration sorting fails" { + cat >"$TEST_TMPDIR/bin/sort" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK + chmod +x "$TEST_TMPDIR/bin/sort" + export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_REPOSITORIES_0=api + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'failed to sort plugin configuration variables'* ]] + [[ ! -e "$MOCK_LOG" ]] +} + +@test "fails when JWT claims cannot be generated" { + cat >"$TEST_TMPDIR/bin/jq" <<'MOCK' +#!/usr/bin/env bash +exit 1 +MOCK + chmod +x "$TEST_TMPDIR/bin/jq" + source "$BATS_TEST_DIRNAME/../lib/plugin.bash" + run plugin_create_jwt Iv1.0123456789abcdef "$TEST_PRIVATE_KEY" + [[ "$status" -ne 0 ]] + [[ "$output" == *'failed to encode JWT claims'* ]] + [[ -z "$(find "$TMPDIR" -type f -print -quit)" ]] +} + +@test "revokes a minted token when private state cannot be written" { + cat >"$TEST_TMPDIR/bin/mktemp" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == -d ]]; then + state="$TMPDIR/github-app-token-state.unwritable" + mkdir "$state" + chmod 500 "$state" + printf '%s\n' "$state" +else + exec "$TEST_REAL_MKTEMP" "$@" +fi +MOCK + chmod +x "$TEST_TMPDIR/bin/mktemp" + run create_token + [[ "$status" -ne 0 ]] + [[ "$output" == *'could not write private token state'* ]] + grep -F 'DELETE https://api.github.com/installation/token' "$MOCK_LOG" + [[ -z "${GITHUB_TOKEN:-}" ]] + [[ -z "$(find "$TMPDIR" -type f -print -quit)" ]] +} + @test "reports sanitized token API failures" { export MOCK_TOKEN_STATUS=401 run create_token From 3d4e4184e4f05348c8970224a13bd25a88ce3b25 Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 24 Aug 2026 07:32:43 +0000 Subject: [PATCH 4/6] Consume private key env and ignore curl config Amp-Thread-ID: https://ampcode.com/threads/T-01a0277c-8ac1-77fc-91b8-f1fc859fe02f Co-authored-by: Lachlan Donald --- README.md | 2 +- lib/plugin.bash | 5 ++++- tests/plugin.bats | 7 +++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 46ad7e6..c86c62a 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Configure exactly one identity source. When compatibility fields overlap, `clien Exactly one is required: - **`private-key-secret`**: Name of a Buildkite Secret. The hook calls `buildkite-agent secret get`. -- **`private-key-env`**: Name of an environment variable populated by Buildkite Secrets or another secret manager. +- **`private-key-env`**: Name of an environment variable populated by Buildkite Secrets or another secret manager. The plugin consumes and unsets this variable before the command runs. Both normal multi-line PEM and PEM containing literal `\n` separators are accepted. Temporary key files are mode `0600` under a private umask and are removed immediately after signing. diff --git a/lib/plugin.bash b/lib/plugin.bash index f68b762..b28961d 100644 --- a/lib/plugin.bash +++ b/lib/plugin.bash @@ -61,7 +61,7 @@ plugin_api_request() { args+=(--header 'Content-Type: application/json' --data "$payload") fi - status="$(printf 'header = "Authorization: Bearer %s"\n' "$auth" | curl --config - "${args[@]}" "${GITHUB_APP_API_URL}${path}")" || { + status="$(printf 'header = "Authorization: Bearer %s"\n' "$auth" | curl -q --config - "${args[@]}" "${GITHUB_APP_API_URL}${path}")" || { GITHUB_APP_HTTP_STATUS=unknown GITHUB_APP_HTTP_MESSAGE='GitHub API request failed' plugin_fail "GitHub API request failed for ${method} ${path}" @@ -293,6 +293,9 @@ github_app_token_create() { plugin_target "$(plugin_config owner)" || return jwt="$(plugin_create_jwt "$client_id" "$private_key")" || return unset private_key + if [[ -n "$private_key_env" ]]; then + unset "$private_key_env" + fi response="$(mktemp "${TMPDIR:-/tmp}/github-app-response.XXXXXX")" installation_id="$(plugin_config installation-id)" diff --git a/tests/plugin.bats b/tests/plugin.bats index 0cec58f..ff564e4 100755 --- a/tests/plugin.bats +++ b/tests/plugin.bats @@ -61,6 +61,8 @@ MOCK set -euo pipefail method=GET output= payload= url= config= auth= +[[ "$1" == -q ]] +shift while (($#)); do [[ "$1" != *Bearer* ]] || exit 9 case "$1" in @@ -157,6 +159,11 @@ create_token() { [[ "$(tail -1 "$MOCK_LOG")" == redactor:* ]] } +@test "removes the referenced private key variable before the command" { + create_token + [[ -z "${TEST_PRIVATE_KEY+x}" ]] +} + @test "retrieves a private key from Buildkite Secrets" { unset BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PRIVATE_KEY_ENV export BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_PRIVATE_KEY_SECRET=GITHUB_APP_PRIVATE_KEY From d4c0c72d0e8a9dd7f1d58fcc8741333ae2f29e6b Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 24 Aug 2026 07:35:22 +0000 Subject: [PATCH 5/6] Make failure tests root-safe Amp-Thread-ID: https://ampcode.com/threads/T-01a0277c-8ac1-77fc-91b8-f1fc859fe02f Co-authored-by: Lachlan Donald --- tests/plugin.bats | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/plugin.bats b/tests/plugin.bats index ff564e4..3795e4a 100755 --- a/tests/plugin.bats +++ b/tests/plugin.bats @@ -7,6 +7,7 @@ setup() { mkdir -p "$TMPDIR" "$TEST_TMPDIR/bin" openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$TEST_TMPDIR/key.pem" 2>/dev/null export TEST_PRIVATE_KEY="$(cat "$TEST_TMPDIR/key.pem")" + export TEST_REAL_CAT="$(command -v cat)" export TEST_REAL_MKTEMP="$(command -v mktemp)" export PATH="$TEST_TMPDIR/bin:$PATH" export BUILDKITE_REPO="git@github.com:acme/widgets.git" @@ -218,7 +219,16 @@ create_token() { @test "unreadable state does not make cleanup fatal" { create_token state="$BUILDKITE_PLUGIN_CREATE_GITHUB_APP_TOKEN_STATE_DIR" - chmod 000 "$state/token" + export MOCK_UNREADABLE_STATE="$state/token" + cat >"$TEST_TMPDIR/bin/cat" <<'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "$MOCK_UNREADABLE_STATE" ]]; then + exit 1 +fi +exec "$TEST_REAL_CAT" "$@" +MOCK + chmod +x "$TEST_TMPDIR/bin/cat" + hash -r run github_app_token_cleanup [[ "$status" -eq 0 ]] [[ "$output" == *'failed to read token state; revocation was not attempted'* ]] @@ -273,10 +283,7 @@ MOCK cat >"$TEST_TMPDIR/bin/mktemp" <<'MOCK' #!/usr/bin/env bash if [[ "${1:-}" == -d ]]; then - state="$TMPDIR/github-app-token-state.unwritable" - mkdir "$state" - chmod 500 "$state" - printf '%s\n' "$state" + printf '%s\n' /proc/github-app-token-state.unwritable else exec "$TEST_REAL_MKTEMP" "$@" fi From e4126cb19e4c2f7fe586d9b94eea3059a83f146c Mon Sep 17 00:00:00 2001 From: Lachlan Donald Date: Tue, 25 Aug 2026 08:29:43 +1000 Subject: [PATCH 6/6] Update plugin.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Łukasz Jendrysik <10155318+scadu@users.noreply.github.com> --- plugin.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.yml b/plugin.yml index 3665f05..c6cabee 100644 --- a/plugin.yml +++ b/plugin.yml @@ -1,6 +1,6 @@ name: Create GitHub App Token description: Creates a short-lived, least-privilege GitHub App installation access token -author: https://github.com/buildkite +author: https://github.com/buildkite-plugins requirements: - bash - buildkite-agent