From 72f8ae3fa483e60500cfa6fd0c836e0092625950 Mon Sep 17 00:00:00 2001 From: Louis Parkin Date: Mon, 3 Aug 2026 16:47:51 +0200 Subject: [PATCH 1/2] STAC-25509 Fix scheduled dependency update blocked by the Actions allowlist The daily "Update Datadog upstream dependency" workflow has failed with startup_failure on every run since the GitHub CI migration merged, so the DataDog upstream dependency is no longer tracked automatically. The cause is peter-evans/create-pull-request: StackVista's Actions allowlist does not permit it (the repo's selected-actions policy has no peter-evans/* pattern), so GitHub refuses to start the run. A startup failure produces no job and no logs, which is why it went unnoticed. Replaced with allowlisted tooling: - actions/create-github-app-token (GitHub-owned) mints a short-lived updatecli App installation token scoped to this repository. - .github/scripts/open-signed-pr.sh creates the branch, the commit and the PR through the GitHub API. Two constraints shaped that script. master has required_signatures: true, and the GraphQL createCommitOnBranch mutation is the only way to get GitHub to sign a commit made on behalf of a token, so the commit is built as base64 file additions/deletions rather than pushed over git. Separately, the default GITHUB_TOKEN is deliberately left read-only: events raised with it are subject to GitHub's recursion-prevention rule, so a PR opened with it would never trigger the required "Process-agent CI" check and could never merge. docker-images documents the same reasoning for its bump workflows. The script is idempotent. It resets an existing working branch to the rebuilt tree, guards the commit with expectedHeadOid so a concurrent update fails loudly, and updates an already-open PR in place instead of creating a duplicate. A concurrency group stops two scheduled runs racing. Requires UPDATECLI_GH_APP_CLIENT_ID and UPDATECLI_GH_APP_PRIVATE_KEY at repo level and the updatecli App installed here; the Pulumi config secret already exists in the github_repoVariables stack, so no new secret value is needed. Validated: bash -n and shellcheck clean, actionlint clean apart from a pre-existing style note in an untouched step, zizmor reports no findings, the commit payload was exercised against a real add/modify/delete change set, and the mutation shape was checked against GitHub's live GraphQL schema. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/open-signed-pr.sh | 123 ++++++++++++++++++ .../workflows/update-datadog-dependency.yml | 55 +++++--- 2 files changed, 162 insertions(+), 16 deletions(-) create mode 100755 .github/scripts/open-signed-pr.sh diff --git a/.github/scripts/open-signed-pr.sh b/.github/scripts/open-signed-pr.sh new file mode 100755 index 00000000..5626c47d --- /dev/null +++ b/.github/scripts/open-signed-pr.sh @@ -0,0 +1,123 @@ +#!/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". +mapfile -t changed_files < <(git diff --name-only --diff-filter=d) +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}" + +additions='[]' +if [ ${#changed_files[@]} -gt 0 ]; then + additions=$( + for f in "${changed_files[@]}"; do + jq -n --arg path "$f" --arg contents "$(base64 -w0 "$f")" \ + '{path: $path, contents: $contents}' + done | jq -s '.' + ) +fi + +deletions='[]' +if [ ${#deleted_files[@]} -gt 0 ]; then + deletions=$(printf '%s\n' "${deleted_files[@]}" | jq -R '{path: .}' | jq -s '.') +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=$(mktemp) +trap 'rm -f "${payload}"' EXIT + +jq -n \ + --arg repo "${GH_REPO}" \ + --arg branch "${HEAD_BRANCH}" \ + --arg message "${COMMIT_MESSAGE}" \ + --arg oid "${BASE_SHA}" \ + --argjson additions "${additions}" \ + --argjson 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, + deletions: $deletions + } + } + } + }' > "${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' From e9d8a6eaae4a64fff053bd4996e6650a45e0ad11 Mon Sep 17 00:00:00 2001 From: Louis Parkin Date: Tue, 4 Aug 2026 10:56:01 +0200 Subject: [PATCH 2/2] STAC-25509 Keep file contents out of argv when building the commit payload The first real run of this workflow failed with open-signed-pr.sh: line 58: /usr/bin/jq: Argument list too long go.sum is 219 KB, so its base64 encoding is roughly 292 KB. Passing that to jq as `--arg contents "$(base64 -w0 "$f")"` exceeds MAX_ARG_STRLEN, the Linux 128 KiB ceiling on a single argument, and exec fails with E2BIG. The same applied to `--argjson additions`, which carried every encoded file at once. Bulk data now reaches jq through files: --rawfile for the encoded blob and --slurpfile for the assembled arrays, so nothing large passes through argv. The two temporary paths are consolidated under one directory, since the second trap would otherwise have replaced the first and leaked the payload. Also collect untracked files. git diff only reports tracked paths, so a file the updater created would have been left out of the commit silently. Verified locally against a scratch repository with a 232 KB go.sum: the previous script reproduces the CI failure exactly (exit 126), the new one commits a modified file, an oversized file, an untracked file and a deletion, with contents round-tripping byte for byte. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/open-signed-pr.sh | 52 +++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/.github/scripts/open-signed-pr.sh b/.github/scripts/open-signed-pr.sh index 5626c47d..c26ca4b6 100755 --- a/.github/scripts/open-signed-pr.sh +++ b/.github/scripts/open-signed-pr.sh @@ -34,8 +34,15 @@ 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". -mapfile -t changed_files < <(git diff --name-only --diff-filter=d) +# 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 @@ -46,19 +53,31 @@ fi echo "Changed: ${changed_files[*]:-none}" echo "Deleted: ${deleted_files[*]:-none}" -additions='[]' +# 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 - additions=$( - for f in "${changed_files[@]}"; do - jq -n --arg path "$f" --arg contents "$(base64 -w0 "$f")" \ - '{path: $path, contents: $contents}' - done | jq -s '.' - ) + : > "${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='[]' +deletions="${work}/deletions.json" +echo '[]' > "${deletions}" if [ ${#deleted_files[@]} -gt 0 ]; then - deletions=$(printf '%s\n' "${deleted_files[@]}" | jq -R '{path: .}' | jq -s '.') + 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 @@ -74,16 +93,15 @@ else -f ref="refs/heads/${HEAD_BRANCH}" -F sha="${BASE_SHA}" >/dev/null fi -payload=$(mktemp) -trap 'rm -f "${payload}"' EXIT +payload="${work}/payload.json" jq -n \ --arg repo "${GH_REPO}" \ --arg branch "${HEAD_BRANCH}" \ --arg message "${COMMIT_MESSAGE}" \ --arg oid "${BASE_SHA}" \ - --argjson additions "${additions}" \ - --argjson deletions "${deletions}" \ + --slurpfile additions "${additions}" \ + --slurpfile deletions "${deletions}" \ '{ query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }", variables: { @@ -95,8 +113,8 @@ jq -n \ message: { headline: $message }, expectedHeadOid: $oid, fileChanges: { - additions: $additions, - deletions: $deletions + additions: $additions[0], + deletions: $deletions[0] } } }