From c90ba01a81ec65d815711280d3b9810a85d06ad8 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:02:41 -0700 Subject: [PATCH] ci: create verified release commits with the GitHub App release-samples.sh pushed a local commit, so the first commit on each release branch was unsigned: the App token authenticated the push but did not sign the commit. Create the commit through the Git database API with the App token instead, which GitHub signs for bots, and publish the release branch only after GitHub reports the commit as verified. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .github/scripts/release-samples.sh | 79 ++++++++++++++++++++++++++++-- .github/workflows/release.yaml | 18 +++---- 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/.github/scripts/release-samples.sh b/.github/scripts/release-samples.sh index db787279..27591c38 100755 --- a/.github/scripts/release-samples.sh +++ b/.github/scripts/release-samples.sh @@ -16,7 +16,7 @@ # limitations under the License. # ------------------------------------------------------------ -set -xe +set -xeo pipefail VERSION_NUMBER=$1 # (e.g. 0.1.0) REPOSITORY="samples" @@ -26,6 +26,14 @@ if [[ -z "$VERSION_NUMBER" ]]; then exit 1 fi +# The release commit is created through the GitHub API in GITHUB_REPOSITORY (owner/name) using GH_TOKEN, +# which must be a GitHub App installation token. BOT_NAME and BOT_EMAIL identify the App's bot user for +# the DCO sign-off. +if [[ -z "${GITHUB_REPOSITORY}" || -z "${BOT_NAME}" || -z "${BOT_EMAIL}" ]]; then + echo "Error: GITHUB_REPOSITORY, BOT_NAME, and BOT_EMAIL must be set." + exit 1 +fi + # CHANNEL is the major and minor version of the VERSION_NUMBER (e.g. 0.1) CHANNEL="$(echo $VERSION_NUMBER | cut -d '.' -f 1,2)" @@ -40,7 +48,8 @@ echo "Creating release branch for ${REPOSITORY}..." pushd $REPOSITORY -git checkout -B "${CHANNEL_VERSION}" +# The release branch starts from the checked-out commit +BASE_SHA="$(git rev-parse HEAD)" # Update bicepconfig.json br:biceptypes.azurecr.io/radius with the CHANNEL BICEPCONFIG_RADIUS_STRING_REPLACEMENT="br:biceptypes.azurecr.io/radius:${CHANNEL}" @@ -54,11 +63,71 @@ mv bicepconfig_updated.json bicepconfig.json echo "View updated bicepconfig..." cat bicepconfig.json -# Push changes to GitHub echo "Running git diff..." git diff git add --all -git commit --signoff --message "Update samples for ${CHANNEL_VERSION}" -git push origin "${CHANNEL_VERSION}" + +if git diff --cached --quiet; then + echo "Error: No changes to commit for ${CHANNEL_VERSION}." + exit 1 +fi + +# Create the release commit with the GitHub API instead of pushing a local commit. GitHub signs commits that a +# GitHub App creates this way when the request has no custom author, committer, or signature: +# https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#signature-verification-for-bots +echo "Uploading changed files..." +TREE_ENTRIES="" +while IFS= read -r -d '' CHANGE && IFS= read -r -d '' CHANGED_PATH; do + read -r OLD_MODE NEW_MODE _ NEW_SHA CHANGE_TYPE <<<"${CHANGE#:}" + case "${CHANGE_TYPE}" in + A | M | T) + BLOB_SHA="$(git cat-file blob "${NEW_SHA}" | base64 --wrap=0 | + jq --raw-input --slurp '{content: ., encoding: "base64"}' | + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/blobs" --input - --jq '.sha')" + TREE_ENTRIES+="$(jq --null-input --compact-output --arg path "${CHANGED_PATH}" --arg mode "${NEW_MODE}" \ + --arg sha "${BLOB_SHA}" '{path: $path, mode: $mode, type: "blob", sha: $sha}')"$'\n' + ;; + D) + TREE_ENTRIES+="$(jq --null-input --compact-output --arg path "${CHANGED_PATH}" --arg mode "${OLD_MODE}" \ + '{path: $path, mode: $mode, type: "blob", sha: null}')"$'\n' + ;; + *) + echo "Error: Unsupported change type ${CHANGE_TYPE} for ${CHANGED_PATH}." + exit 1 + ;; + esac +done < <(git diff-index --cached --no-renames -z HEAD) + +TREE_SHA="$(jq --slurp --arg base_tree "$(git rev-parse 'HEAD^{tree}')" '{base_tree: $base_tree, tree: .}' <<<"${TREE_ENTRIES}" | + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/trees" --input - --jq '.sha')" + +# The commit must contain exactly the staged changes, like a local `git commit` +if [[ "${TREE_SHA}" != "$(git write-tree)" ]]; then + echo "Error: GitHub tree ${TREE_SHA} does not match the local changes." + exit 1 +fi + +echo "Creating commit..." +COMMIT="$(jq --null-input \ + --arg message "$(printf 'Update samples for %s\n\nSigned-off-by: %s <%s>' "${CHANNEL_VERSION}" "${BOT_NAME}" "${BOT_EMAIL}")" \ + --arg tree "${TREE_SHA}" \ + --arg parent "${BASE_SHA}" \ + '{message: $message, tree: $tree, parents: [$parent]}' | + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/commits" --input -)" +COMMIT_SHA="$(jq --raw-output '.sha' <<<"${COMMIT}")" + +# Nothing has been published yet, so stop here if GitHub did not sign the commit +if [[ "$(jq --raw-output '.verification.verified' <<<"${COMMIT}")" != "true" ]]; then + echo "Error: GitHub did not verify commit ${COMMIT_SHA} ($(jq --raw-output '.verification.reason' <<<"${COMMIT}")). GH_TOKEN must be a GitHub App installation token." + exit 1 +fi + +# Publish the commit. An existing release branch is only fast-forwarded, like `git push`. +echo "Pushing ${COMMIT_SHA} to ${CHANNEL_VERSION}..." +if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${CHANNEL_VERSION}" --silent 2>/dev/null; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/${CHANNEL_VERSION}" -f sha="${COMMIT_SHA}" -F force=false --silent +else + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/heads/${CHANNEL_VERSION}" -f sha="${COMMIT_SHA}" --silent +fi popd diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 142bee80..91f7b4ed 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -35,7 +35,7 @@ jobs: repositories: samples # Resolve the bot's GitHub-formatted name and email dynamically from the app slug - # so commits are attributed to the bot identity rather than hardcoded values. + # so the release commit's DCO sign-off matches the bot that authors it. - name: Get bot details id: bot-details uses: raven-actions/bot-details@ee8966a9ff6e7e42cbfc4a56b4ddb60a9d1b40a6 # v1.2.0 @@ -47,15 +47,7 @@ jobs: token: ${{ steps.app-token.outputs.token }} ref: edge path: samples - persist-credentials: true - - - name: Configure git - run: | - git config --global user.name "${GIT_USER_NAME}" - git config --global user.email "${GIT_USER_EMAIL}" - env: - GIT_USER_NAME: ${{ steps.bot-details.outputs.name }} - GIT_USER_EMAIL: ${{ steps.bot-details.outputs.email }} + persist-credentials: false - name: Ensure inputs.version is valid semver run: | @@ -68,9 +60,15 @@ jobs: CHANNEL="$(echo ${{ inputs.version }} | cut -d '.' -f 1,2)" echo "channel=$CHANNEL" >> "${GITHUB_OUTPUT}" + # The script creates the release commit through the GitHub API with the App token, + # so GitHub signs it and marks it as verified. - name: Release samples run: | ./samples/.github/scripts/release-samples.sh ${{ inputs.version }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BOT_NAME: ${{ steps.bot-details.outputs.name }} + BOT_EMAIL: ${{ steps.bot-details.outputs.email }} - name: Change the default branch run: |