From ebce5e41faea4659d44378f225b60210097e70eb Mon Sep 17 00:00:00 2001 From: Roberto Tyley Date: Fri, 9 Feb 2024 18:10:19 +0000 Subject: [PATCH] Use GitHub App & REST PUT API for updating version.sbt This changes the way we authenticate and make the 1 or 2 updates to `version.sbt` required for a release. Before: * Authenticate as: default `github-actions` bot * version.sbt update method: Cherry-pick the commits created by sbt-release, then push them to GitHub using `git push`, with the default `github-actions` bot using its credentials to make the push After: * Authenticate as: `gu-scala-library-release` GitHub App - https://github.com/apps/gu-scala-library-release * version.sbt update method: GitHub REST API for Repository Contents (PUT /repos/{owner}/{repo}/contents/{path}) https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#create-or-update-file-contents Now we're not really specifying the whole commit, just the content change to one file. This has a few different benefits: * Addresses the need to coexist with our branch-protection rulesets, because GitHub Apps can be exempted from rules, as discussed in issue #5 * Produces `Verified` commits - the commits show up as `Verified` in the GitHub UI, and have a `gpgsig` header entry that is signed by GitHub itself, essentially GitHub attesting that the author of the commit authenticated with GitHub to perform the file update. https://git-scm.com/docs/signature-format#_commit_signatures https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#signature-verification-for-bots https://blog.gitbutler.com/signing-commits-in-git-explained/#github-verification The commits now appear to be attributed to `gu-scala-library-release`, rather than, eg, '@rtyley using gha-scala-library-release-workflow' - it's a bit of shame that the person triggering the release is no longer so clearly visible, but it's probably less confusing. To compensate for that, the commit message itself has been updated to specifically state the responsible user. --- .github/workflows/reusable-release.yml | 114 +++++++++++++++++++------ 1 file changed, 88 insertions(+), 26 deletions(-) diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 2ae26f5..4062f11 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -3,6 +3,13 @@ name: Scala Library Release Workflow on: workflow_call: inputs: + GITHUB_APP_ID: + description: + "App ID for a GitHub App that is allowed to push directly to the default branch. Eg, App ID on: + https://github.com/organizations/guardian/settings/apps/gu-scala-library-release" + default: '807361' # Only for use by the Guardian! + required: false # ...but if you're not the Guardian, you'll want to set this explicitly + type: string SONATYPE_PROFILE_NAME: description: 'Sonatype account profile name, eg "com.gu", "org.xerial", etc (not your Sonatype username)' default: 'com.gu' # Only for use by the Guardian! @@ -28,6 +35,11 @@ on: Should be in normal plaintext 'BEGIN PGP PUBLIC KEY BLOCK' (ASCII-armored) format, with no additional BASE64-encoding. The passphrase can be removed from an existing key using 'gpg --edit-key passwd' : https://unix.stackexchange.com/a/550538/46453" required: true + GITHUB_APP_PRIVATE_KEY: + description: + "See https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps#generating-private-keys + Should be in normal plaintext '-----BEGIN RSA PRIVATE KEY-----' format" + required: true outputs: RELEASE_VERSION: description: "The un-prefixed version number of the release, eg '3.0.1'" @@ -40,6 +52,7 @@ env: LOCAL_ARTIFACTS_STAGING_PATH: /tmp/artifact_staging COMMITTER_NAME: "@${{github.actor}} using gha-scala-library-release-workflow" RUN_ATTEMPT_UID: ${{ github.run_id }}-${{ github.run_attempt }} + TEMPORARY_BRANCH: preliminary-${{ github.run_id }} jobs: init: @@ -143,9 +156,16 @@ jobs: release_version: ${{ steps.create-commit.outputs.release_version }} release_commit_id: ${{ steps.create-commit.outputs.release_commit_id }} steps: + - id: generate-github-app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ inputs.GITHUB_APP_ID }} + private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} } - uses: actions/checkout@v4 with: path: repo + token: ${{ steps.generate-github-app-token.outputs.token }} + persist-credentials: true # Allow us to push as the GitHub App, and bypass branch ruleset - uses: actions/cache/restore@v4 with: path: repo-with-unsigned-version-update-commits.git @@ -161,47 +181,53 @@ jobs: env: KEY_FINGERPRINT: ${{ needs.init.outputs.key_fingerprint }} KEY_EMAIL: ${{ needs.init.outputs.key_email }} + GH_TOKEN: ${{ steps.generate-github-app-token.outputs.token }} run: | echo "GITHUB_REF_NAME=$GITHUB_REF_NAME" echo "GITHUB_REF=$GITHUB_REF" - + cd repo-with-unsigned-version-update-commits.git RELEASE_TAG=$(git describe --tags --abbrev=0) cd ../repo - git status - git config user.email "$KEY_EMAIL" - git config user.name "$COMMITTER_NAME" - git config commit.gpgsign true - git config user.signingkey "$KEY_FINGERPRINT" git remote add unsigned ../repo-with-unsigned-version-update-commits.git git fetch unsigned - git cherry-pick -S$KEY_FINGERPRINT $GITHUB_REF_NAME..unsigned/$GITHUB_REF_NAME - git status - - release_commit_id=$(git rev-parse HEAD^) - + + VERSION_FILE_PATH="version.sbt" # TODO, work it out from diff? + VERSION_FILE_INITIAL_SHA=$( git rev-parse $GITHUB_REF:$VERSION_FILE_PATH ) + VERSION_FILE_RELEASE_SHA=$( git rev-parse $RELEASE_TAG:$VERSION_FILE_PATH ) + VERSION_FILE_RELEASE_CONTENT=$( git cat-file blob $RELEASE_TAG:$VERSION_FILE_PATH | base64 ) + VERSION_FILE_POST_RELEASE_CONTENT=$( git cat-file blob unsigned/$GITHUB_REF_NAME:$VERSION_FILE_PATH | base64 ) + + echo "VERSION_FILE_PATH=$VERSION_FILE_PATH" + echo "VERSION_FILE_INITIAL_SHA=$VERSION_FILE_INITIAL_SHA" + echo "VERSION_FILE_RELEASE_SHA=$VERSION_FILE_RELEASE_SHA" + echo "VERSION_FILE_RELEASE_CONTENT=$VERSION_FILE_RELEASE_CONTENT" + echo "VERSION_FILE_POST_RELEASE_CONTENT=$VERSION_FILE_POST_RELEASE_CONTENT" + + gh api --method POST /repos/:owner/:repo/git/refs \ + -f ref="refs/heads/$TEMPORARY_BRANCH" \ + -f sha="$GITHUB_SHA" + + commit_subject_prefix="$RELEASE_TAG published by @${{github.actor}}:" + + release_commit_id=$(gh api --method PUT /repos/:owner/:repo/contents/$VERSION_FILE_PATH \ + --field branch="$TEMPORARY_BRANCH" \ + --field message="$commit_subject_prefix set version for release" \ + --field sha="$VERSION_FILE_INITIAL_SHA" \ + --field content="$VERSION_FILE_RELEASE_CONTENT" --jq '.commit.sha') + cat << EndOfFile >> $GITHUB_OUTPUT release_tag=$RELEASE_TAG release_version=${RELEASE_TAG#"v"} release_commit_id=$release_commit_id + commit_subject_prefix=$commit_subject_prefix + version_file_path=$VERSION_FILE_PATH + version_file_release_sha=$VERSION_FILE_RELEASE_SHA + version_file_post_release_content=$VERSION_FILE_POST_RELEASE_CONTENT EndOfFile - git log --format="%h %p %ce %s" --decorate=short -n3 - git status - - if [ "${{ needs.init.outputs.release_type }}" == "FULL_MAIN_BRANCH" ] - then - echo "Full Main-Branch release, pushing 2 commits to the default branch" - git push # push 2 commits (non-snapshot release version, then new snapshot version) onto the default branch - else - tag_for_pushing="preliminary-${{ github.run_id }}" - echo "Preview Feature-Branch release, pushing 1 commit with the temporary tag $tag_for_pushing" - git tag -a -m "Tag created merely to allow _pushing_ the release commit, which gains the signed $RELEASE_TAG tag later on in the workflow" $tag_for_pushing $release_commit_id - git push origin $tag_for_pushing # push only the single release version commit with a disposable tag - fi - create-artifacts: name: 🎊 Create artifacts @@ -251,10 +277,18 @@ jobs: env: KEY_FINGERPRINT: ${{ needs.init.outputs.key_fingerprint }} steps: + - id: generate-github-app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ inputs.GITHUB_APP_ID }} + private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} } - uses: actions/checkout@v4 with: path: repo ref: ${{ needs.push-release-commit.outputs.release_commit_id }} + fetch-depth: 2 # To fast-forward the main branch, we need the commit on main, as well as the release commit + token: ${{ steps.generate-github-app-token.outputs.token }} + persist-credentials: true # Allow us to push as the GitHub App, and bypass branch ruleset - uses: actions/cache/restore@v4 with: path: ${{ env.LOCAL_ARTIFACTS_STAGING_PATH }} @@ -275,6 +309,7 @@ jobs: RELEASE_COMMIT_ID: ${{ needs.push-release-commit.outputs.release_commit_id }} ARTIFACT_SHA256SUMS: ${{ needs.create-artifacts.outputs.ARTIFACT_SHA256SUMS }} KEY_EMAIL: ${{ needs.init.outputs.key_email }} + GH_TOKEN: ${{ steps.generate-github-app-token.outputs.token }} run: | echo "RELEASE_TAG=$RELEASE_TAG" echo "RELEASE_COMMIT_ID=$RELEASE_COMMIT_ID" @@ -284,6 +319,13 @@ jobs: git config tag.gpgSign true git config user.signingkey "$KEY_FINGERPRINT" + if [ "${{ needs.init.outputs.release_type }}" == "FULL_MAIN_BRANCH" ] + then + echo "Full Main-Branch release, fast-forwarding the default branch to the release commit" + git log --oneline -n 3 + git push origin $RELEASE_COMMIT_ID:refs/heads/$GITHUB_REF_NAME + fi + cat << EndOfFile > tag-message.txt Release $RELEASE_TAG initiated by $COMMITTER_NAME @@ -300,6 +342,18 @@ jobs: echo "Pushing tag $RELEASE_TAG" git push origin $RELEASE_TAG + + # Now the release commit has a Git tag, we can definitely clean-up the temporary branch that held it. + gh api --method DELETE /repos/:owner/:repo/git/refs/heads/$TEMPORARY_BRANCH + + if [ "${{ needs.init.outputs.release_type }}" == "FULL_MAIN_BRANCH" ] + then + echo "Full Main-Branch release, making 2nd update (new snapshot version) on the default branch" + gh api --method PUT /repos/:owner/:repo/contents/${{ needs.push-release-commit.outputs.version_file_path }} \ + --field message="${{ needs.push-release-commit.outputs.commit_subject_prefix }} snapshot version post-release" \ + --field sha="${{ needs.push-release-commit.outputs.version_file_release_sha }}" \ + --field content="${{ needs.push-release-commit.outputs.version_file_post_release_content }}" + fi - uses: actions/cache/save@v4 with: path: ${{ env.LOCAL_ARTIFACTS_STAGING_PATH }} @@ -350,10 +404,14 @@ jobs: env: RELEASE_TAG: ${{ needs.push-release-commit.outputs.release_tag }} RELEASE_VERSION: ${{ needs.push-release-commit.outputs.release_version }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} GITHUB_REPO_URL: ${{ github.server_url }}/${{ github.repository }} steps: + - id: generate-github-app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ inputs.GITHUB_APP_ID }} + private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} } - name: Common values run: | GITHUB_ACTIONS_PATH="$GITHUB_REPO_URL/actions" @@ -367,11 +425,15 @@ jobs: EndOfFile - name: Create Github Release if: needs.init.outputs.release_type == 'FULL_MAIN_BRANCH' + env: + GH_TOKEN: ${{ steps.generate-github-app-token.outputs.token }} run: | gh release create $RELEASE_TAG --verify-tag --generate-notes --notes "Release run: $GITHUB_WORKFLOW_RUN_LINK" echo "GitHub Release notes: [$RELEASE_TAG]($GITHUB_REPO_URL/releases/tag/$RELEASE_TAG)" >> $GITHUB_STEP_SUMMARY - name: Update PR with comment if: needs.init.outputs.release_type == 'PREVIEW_FEATURE_BRANCH' + env: + GH_TOKEN: ${{ steps.generate-github-app-token.outputs.token }} run: | cat << EndOfFile > comment_body.txt @${{github.actor}} has published a preview version of this PR with release workflow run $GITHUB_WORKFLOW_RUN_LINK, based on commit ${{ github.sha }}: