diff --git a/.github/scripts/open-signed-pr.sh b/.github/scripts/open-signed-pr.sh new file mode 100755 index 00000000..c26ca4b6 --- /dev/null +++ b/.github/scripts/open-signed-pr.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# Open (or refresh) a pull request whose commit is signed by GitHub. +# +# Why this exists rather than an off-the-shelf action: master requires signed +# commits, and StackVista's Actions allowlist does not include the usual +# create-pull-request action. The GraphQL createCommitOnBranch mutation is the +# only way to have GitHub sign a commit made on behalf of a token, so the +# branch, the commit and the PR are all created through the API here. +# +# Everything is driven by environment variables so no caller input is +# interpolated into this script: +# +# GH_TOKEN GitHub App installation token (contents:write, pull-requests:write) +# GH_REPO owner/name +# BASE_BRANCH branch the PR merges into +# HEAD_BRANCH working branch to create +# COMMIT_MESSAGE headline of the commit +# PR_TITLE pull request title +# PR_BODY pull request body +# +# Reads the working tree for changes relative to HEAD; exits 0 without doing +# anything if there are none. + +set -euo pipefail + +for var in GH_TOKEN GH_REPO BASE_BRANCH HEAD_BRANCH COMMIT_MESSAGE PR_TITLE PR_BODY; do + if [ -z "${!var:-}" ]; then + echo "::error::open-signed-pr: missing required environment variable: ${var}" >&2 + exit 1 + fi +done + +BASE_SHA=$(git rev-parse HEAD) + +# Files the updater touched, split into content changes and removals. The +# lowercase 'd' filter means "everything except deletions". git diff only ever +# reports tracked paths, so brand new files are collected separately - +# otherwise they would be dropped from the commit without any warning. +mapfile -t changed_files < <( + { + git diff --name-only --diff-filter=d + git ls-files --others --exclude-standard + } | sort -u +) +mapfile -t deleted_files < <(git diff --name-only --diff-filter=D) + +if [ ${#changed_files[@]} -eq 0 ] && [ ${#deleted_files[@]} -eq 0 ]; then + echo "No changes in the working tree; nothing to open." + exit 0 +fi + +echo "Changed: ${changed_files[*]:-none}" +echo "Deleted: ${deleted_files[*]:-none}" + +# File contents must never travel through argv. Linux caps a single argument +# at 128 KiB (MAX_ARG_STRLEN), and go.sum alone is well past that once base64 +# encoded, so `jq --arg contents "$(base64 ...)"` dies with E2BIG. Everything +# that can grow is handed to jq through files instead: --rawfile to read the +# encoded blob, --slurpfile to read the assembled arrays. +work=$(mktemp -d) +trap 'rm -rf "${work}"' EXIT + +additions="${work}/additions.json" +echo '[]' > "${additions}" +if [ ${#changed_files[@]} -gt 0 ]; then + : > "${work}/additions.ndjson" + for f in "${changed_files[@]}"; do + base64 -w0 "$f" > "${work}/content.b64" + # base64 -w0 still terminates with a newline; GitHub wants the bare blob. + jq -n --arg path "$f" --rawfile contents "${work}/content.b64" \ + '{path: $path, contents: ($contents | rtrimstr("\n"))}' >> "${work}/additions.ndjson" + done + jq -s '.' "${work}/additions.ndjson" > "${additions}" +fi + +deletions="${work}/deletions.json" +echo '[]' > "${deletions}" +if [ ${#deleted_files[@]} -gt 0 ]; then + printf '%s\n' "${deleted_files[@]}" | jq -R '{path: .}' | jq -s '.' > "${deletions}" +fi + +# Point the working branch at the commit we built from. Creating the commit +# with expectedHeadOid equal to this SHA then fails loudly if anything else +# moved the branch in between. +if gh api "repos/${GH_REPO}/git/ref/heads/${HEAD_BRANCH}" >/dev/null 2>&1; then + echo "Branch ${HEAD_BRANCH} exists; resetting it to ${BASE_SHA}." + gh api --method PATCH "repos/${GH_REPO}/git/refs/heads/${HEAD_BRANCH}" \ + -F sha="${BASE_SHA}" -F force=true >/dev/null +else + echo "Creating branch ${HEAD_BRANCH} at ${BASE_SHA}." + gh api --method POST "repos/${GH_REPO}/git/refs" \ + -f ref="refs/heads/${HEAD_BRANCH}" -F sha="${BASE_SHA}" >/dev/null +fi + +payload="${work}/payload.json" + +jq -n \ + --arg repo "${GH_REPO}" \ + --arg branch "${HEAD_BRANCH}" \ + --arg message "${COMMIT_MESSAGE}" \ + --arg oid "${BASE_SHA}" \ + --slurpfile additions "${additions}" \ + --slurpfile deletions "${deletions}" \ + '{ + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }", + variables: { + input: { + branch: { + repositoryNameWithOwner: $repo, + branchName: $branch + }, + message: { headline: $message }, + expectedHeadOid: $oid, + fileChanges: { + additions: $additions[0], + deletions: $deletions[0] + } + } + } + }' > "${payload}" + +echo "Creating signed commit on ${HEAD_BRANCH}." +commit_url=$(gh api graphql --input "${payload}" --jq '.data.createCommitOnBranch.commit.url') +echo "Commit: ${commit_url}" + +existing=$(gh pr list --repo "${GH_REPO}" --head "${HEAD_BRANCH}" --state open --json url --jq '.[0].url // empty') +if [ -n "${existing}" ]; then + echo "Pull request already open, refreshed in place: ${existing}" + gh pr edit "${existing}" --repo "${GH_REPO}" --title "${PR_TITLE}" --body "${PR_BODY}" >/dev/null + echo "pr_url=${existing}" >> "${GITHUB_OUTPUT:-/dev/null}" + exit 0 +fi + +pr_url=$(gh pr create --repo "${GH_REPO}" \ + --base "${BASE_BRANCH}" \ + --head "${HEAD_BRANCH}" \ + --title "${PR_TITLE}" \ + --body "${PR_BODY}") +echo "Pull request: ${pr_url}" +echo "pr_url=${pr_url}" >> "${GITHUB_OUTPUT:-/dev/null}" diff --git a/.github/workflows/update-datadog-dependency.yml b/.github/workflows/update-datadog-dependency.yml index 1c04a397..b3f75bfb 100644 --- a/.github/workflows/update-datadog-dependency.yml +++ b/.github/workflows/update-datadog-dependency.yml @@ -12,19 +12,37 @@ on: default: 'stackstate-7.62.2' permissions: - contents: write - pull-requests: write + # All write auth below uses a short-lived GitHub App installation token. + # The default GITHUB_TOKEN stays read-only on purpose: events raised with it + # are subject to GitHub's recursion-prevention rule, so a PR it opened would + # never trigger the required "Process-agent CI" check and could not merge. + contents: read + +concurrency: + # Two scheduled/dispatched runs must not race on the same working branch. + group: update-datadog-dependency + cancel-in-progress: false jobs: update-dependency: runs-on: ubuntu-latest steps: + - name: Create updatecli GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.UPDATECLI_GH_APP_CLIENT_ID }} + private-key: ${{ secrets.UPDATECLI_GH_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + repositories: stackstate-process-agent + - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - # No later step pushes via git: the updater only reads, and - # create-pull-request authenticates with its own token input + # Nothing here pushes over git: the branch, the commit and the PR are + # all created through the API using the App token. persist-credentials: false - name: Setup Go @@ -79,13 +97,20 @@ jobs: - name: Create pull request if changes detected if: steps.update.outputs.changed == 'true' - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 - with: - title: | - chore: update datadog-agent upstream (${{ steps.update.outputs.branch }}) -> ${{ steps.update.outputs.short_commit }} - commit-message: | - chore: update datadog-agent upstream to ${{ steps.update.outputs.commit }} (branch ${{ steps.update.outputs.branch }}) - body: | + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_REPO: ${{ github.repository }} + BASE_BRANCH: ${{ github.ref_name }} + HEAD_BRANCH: update-datadog-${{ steps.update.outputs.short_commit }} + COMMIT_MESSAGE: >- + chore: update datadog-agent upstream to + ${{ steps.update.outputs.commit }} + (branch ${{ steps.update.outputs.branch }}) + PR_TITLE: >- + chore: update datadog-agent upstream + (${{ steps.update.outputs.branch }}) -> + ${{ steps.update.outputs.short_commit }} + PR_BODY: | This automated PR updates all github.com/DataDog/datadog-agent module replaces to the upstream mirror at the latest commit. • Branch: `${{ steps.update.outputs.branch }}` @@ -96,11 +121,9 @@ jobs: `./update-datadog-dependency.sh -b "${{ steps.update.outputs.branch }}"` Re-run this workflow with a different `upstream_branch` to refresh the PR. - branch: update-datadog-${{ steps.update.outputs.short_commit }} - delete-branch: true - # master requires signed commits (pulumi-infra branch protection); - # sign-commits creates the commit via the GitHub API so it is verified - sign-commits: true + run: | + chmod +x .github/scripts/open-signed-pr.sh + .github/scripts/open-signed-pr.sh - name: No updates needed if: steps.update.outputs.changed != 'true'