diff --git a/.github/actions/prepare-mergeback-branch/action.yml b/.github/actions/prepare-mergeback-branch/action.yml new file mode 100644 index 0000000000..fcbe7b7f16 --- /dev/null +++ b/.github/actions/prepare-mergeback-branch/action.yml @@ -0,0 +1,77 @@ +name: "Prepare mergeback branch" +description: Prepares a mergeback branch and opens a PR for it +inputs: + base: + description: "The name of the base branch" + required: true + head: + description: "The name of the head branch" + required: true + branch: + description: "The name of the branch to create." + required: true + version: + description: "The new version" + required: true + token: + description: "The token to use" + required: true + dry-run: + description: "Set to true to skip creating the PR. The branch will still be pushed." + default: "false" +runs: + using: composite + steps: + - name: Create mergeback branch + shell: bash + env: + VERSION: "${{ inputs.version }}" + NEW_BRANCH: "${{ inputs.branch }}" + run: | + set -exu + + # Update the version number ready for the next release + npm version patch --no-git-tag-version + + # Update the changelog, adding a new version heading directly above the most recent existing one + awk '!f && /##/{print "'"## [UNRELEASED]\n\nNo user facing changes.\n"'"; f=1}1' CHANGELOG.md > temp && mv temp CHANGELOG.md + git add . + git commit -m "Update changelog and version after ${VERSION}" + + git push origin "${NEW_BRANCH}" + + - name: Create PR + shell: bash + if: inputs.dry-run != 'false' + env: + VERSION: "${{ inputs.version }}" + BASE_BRANCH: "${{ inputs.base }}" + HEAD_BRANCH: "${{ inputs.head }}" + NEW_BRANCH: "${{ inputs.branch }}" + GITHUB_TOKEN: "${{ inputs.token }}" + run: | + set -exu + pr_title="Mergeback ${VERSION} ${HEAD_BRANCH} into ${BASE_BRANCH}" + pr_body=$(cat << EOF + This PR bumps the version number and updates the changelog after the ${VERSION} release. + + Please do the following: + + - [ ] Remove and re-add the "Update dependencies" label to the PR to trigger just this workflow. + - [ ] Wait for the "Update dependencies" workflow to push a commit updating the dependencies. + - [ ] Mark the PR as ready for review to trigger the full set of PR checks. + - [ ] Approve and merge the PR. When merging the PR, make sure "Create a merge commit" is + selected rather than "Squash and merge" or "Rebase and merge". + EOF + ) + + # PR checks won't be triggered on PRs created by Actions. Therefore mark the PR as draft + # so that a maintainer can take the PR out of draft, thereby triggering the PR checks. + gh pr create \ + --head "${NEW_BRANCH}" \ + --base "${BASE_BRANCH}" \ + --title "${pr_title}" \ + --label "Update dependencies" \ + --body "${pr_body}" \ + --assignee "${GITHUB_ACTOR}" \ + --draft diff --git a/.github/workflows/post-release-mergeback.yml b/.github/workflows/post-release-mergeback.yml index f749baaba3..d302e32d83 100644 --- a/.github/workflows/post-release-mergeback.yml +++ b/.github/workflows/post-release-mergeback.yml @@ -124,48 +124,15 @@ jobs: cat $PARTIAL_CHANGELOG echo "::endgroup::" - - name: Create mergeback branch + - name: Create mergeback branch and PR if: ${{ steps.check.outputs.exists != 'true' && endsWith(github.ref_name, steps.getVersion.outputs.latest_release_branch) }} - env: - VERSION: "${{ steps.getVersion.outputs.version }}" - NEW_BRANCH: "${{ steps.getVersion.outputs.newBranch }}" - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - run: | - set -exu - pr_title="Mergeback ${VERSION} ${HEAD_BRANCH} into ${BASE_BRANCH}" - pr_body=$(cat << EOF - This PR bumps the version number and updates the changelog after the ${VERSION} release. - - Please do the following: - - - [ ] Remove and re-add the "Update dependencies" label to the PR to trigger just this workflow. - - [ ] Wait for the "Update dependencies" workflow to push a commit updating the dependencies. - - [ ] Mark the PR as ready for review to trigger the full set of PR checks. - - [ ] Approve and merge the PR. When merging the PR, make sure "Create a merge commit" is - selected rather than "Squash and merge" or "Rebase and merge". - EOF - ) - - # Update the version number ready for the next release - npm version patch --no-git-tag-version - - # Update the changelog, adding a new version heading directly above the most recent existing one - awk '!f && /##/{print "'"## [UNRELEASED]\n\nNo user facing changes.\n"'"; f=1}1' CHANGELOG.md > temp && mv temp CHANGELOG.md - git add . - git commit -m "Update changelog and version after ${VERSION}" - - git push origin "${NEW_BRANCH}" - - # PR checks won't be triggered on PRs created by Actions. Therefore mark the PR as draft - # so that a maintainer can take the PR out of draft, thereby triggering the PR checks. - gh pr create \ - --head "${NEW_BRANCH}" \ - --base "${BASE_BRANCH}" \ - --title "${pr_title}" \ - --label "Update dependencies" \ - --body "${pr_body}" \ - --assignee "${GITHUB_ACTOR}" \ - --draft + uses: ./.github/actions/prepare-mergeback-branch + with: + base: "${{ env.BASE_BRANCH }}" + head: "${{ env.HEAD_BRANCH }}" + branch: "${{ steps.getVersion.outputs.newBranch }}" + version: "${{ steps.getVersion.outputs.version }}" + token: "${{ secrets.GITHUB_TOKEN }}" - name: Generate token uses: actions/create-github-app-token@v2.1.1 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000000..7678870cc6 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,73 @@ +name: Prepare release +on: + workflow_call: + outputs: + version: + description: "The version that is being released." + value: ${{ jobs.prepare.outputs.version }} + major_version: + description: "The major version of the release." + value: ${{ jobs.prepare.outputs.major_version }} + latest_tag: + description: "The most recent, existing release tag." + value: ${{ jobs.prepare.outputs.latest_tag }} + backport_source_branch: + description: "The release branch for the given tag." + value: ${{ jobs.prepare.outputs.backport_source_branch }} + backport_target_branches: + description: "JSON encoded list of branches to target with backports." + value: ${{ jobs.prepare.outputs.backport_target_branches }} + + push: + paths: + - .github/workflows/prepare-release.yml + +jobs: + prepare: + name: "Prepare release" + runs-on: ubuntu-latest + if: github.repository == 'github/codeql-action' + + permissions: + contents: read + + outputs: + version: ${{ steps.versions.outputs.version }} + major_version: ${{ steps.versions.outputs.major_version }} + latest_tag: ${{ steps.versions.outputs.latest_tag }} + backport_source_branch: ${{ steps.branches.outputs.backport_source_branch }} + backport_target_branches: ${{ steps.branches.outputs.backport_target_branches }} + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 # Need full history for calculation of diffs + + - name: Configure runner for release + uses: ./.github/actions/release-initialise + + - name: Get version tags + id: versions + run: | + VERSION="v$(jq '.version' -r 'package.json')" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + MAJOR_VERSION=$(cut -d '.' -f1 <<< "${VERSION}") + echo "major_version=${MAJOR_VERSION}" >> $GITHUB_OUTPUT + LATEST_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | head -1) + echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT + + - name: Determine older release branches + id: branches + uses: ./.github/actions/release-branches + with: + major_version: ${{ steps.versions.outputs.major_version }} + latest_tag: ${{ steps.versions.outputs.latest_tag }} + + - name: Print release information + run: | + echo 'version: ${{ steps.versions.outputs.version }}' + echo 'major_version: ${{ steps.versions.outputs.major_version }}' + echo 'latest_tag: ${{ steps.versions.outputs.latest_tag }}' + echo 'backport_source_branch: ${{ steps.branches.outputs.backport_source_branch }}' + echo 'backport_target_branches: ${{ steps.branches.outputs.backport_target_branches }}' diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml new file mode 100644 index 0000000000..a407cc17ee --- /dev/null +++ b/.github/workflows/rollback-release.yml @@ -0,0 +1,160 @@ +name: Rollback release +on: + # You can trigger this workflow via workflow dispatch to start a rollback. + # This will create a draft release that mirrors the release for `rollback-tag`. + workflow_dispatch: + inputs: + rollback-tag: + type: string + description: "The tag of an old release to roll-back to." + required: true + # Only for dry-runs of changes to the workflow. + push: + paths: + - .github/workflows/rollback-release.yml + +jobs: + prepare: + name: "Prepare release" + if: github.repository == 'github/codeql-action' + + permissions: + contents: read + + uses: ./.github/workflows/prepare-release.yml + + rollback: + name: "Create rollback release" + if: github.repository == 'github/codeql-action' + runs-on: ubuntu-latest + timeout-minutes: 45 + + # Don't set the deployment environment for test runs + environment: ${{ github.event_name == 'workflow_dispatch' && 'Automation' || '' }} + + needs: + - prepare + + permissions: + contents: write # needed to push to the repo (tags and releases) + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 # Need full history for calculation of diffs + + - name: Configure runner for release + uses: ./.github/actions/release-initialise + + - name: Create tag for testing + if: github.event_name != 'workflow_dispatch' + shell: bash + run: git tag v0.0.0 + + # We start by preparing the mergeback branch, mainly so that we have the updated changelog + # readily available for the partial changelog that's needed for the release. + - name: Prepare mergeback branch + id: mergeback-branch + env: + BASE_BRANCH: ${{ (github.event_name == 'workflow_dispatch' && 'main') || github.ref_name }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -x + + # Checkout the base branch, since we may be testing on a different branch + git checkout "$BASE_BRANCH" + + # Generate a new branch name for the mergeback PR + short_sha="${GITHUB_SHA:0:8}" + NEW_BRANCH="mergeback/${VERSION}-to-${BASE_BRANCH}-${short_sha}" + echo "new-branch=${NEW_BRANCH}" >> $GITHUB_OUTPUT + + # Create the mergeback branch + git checkout -b "${NEW_BRANCH}" + + - name: Prepare rollback changelog + env: + NEW_CHANGELOG: "${{ runner.temp }}/new_changelog.md" + # We usually expect to checkout `inputs.rollback-tag` (required for `workflow_dispatch`), + # but use `v0.0.0` for testing. + ROLLBACK_TAG: ${{ inputs.rollback-tag || 'v0.0.0' }} + LATEST_TAG: ${{ needs.prepare.outputs.latest_tag }} + VERSION: "${{ needs.prepare.outputs.version }}" + run: | + python .github/workflows/script/rollback_changelog.py "${ROLLBACK_TAG:1}" "${LATEST_TAG:1}" "$VERSION" > $NEW_CHANGELOG + + echo "::group::New CHANGELOG" + cat $NEW_CHANGELOG + echo "::endgroup::" + + - name: Create tags + shell: bash + env: + # We usually expect to checkout `inputs.rollback-tag` (required for `workflow_dispatch`), + # but use `v0.0.0` for testing. + ROLLBACK_TAG: ${{ inputs.rollback-tag || 'v0.0.0' }} + RELEASE_TAG: ${{ needs.prepare.outputs.version }} + MAJOR_VERSION_TAG: ${{ needs.prepare.outputs.major_version }} + run: | + git checkout "refs/tags/${ROLLBACK_TAG}" + git tag --annotate "${RELEASE_TAG}" --message "${RELEASE_TAG}" + git tag --annotate "${MAJOR_VERSION_TAG}" --message "${MAJOR_VERSION_TAG}" --force + + - name: Push tags + if: github.event_name == 'workflow_dispatch' + shell: bash + env: + RELEASE_TAG: ${{ needs.prepare.outputs.version }} + MAJOR_VERSION_TAG: ${{ needs.prepare.outputs.major_version }} + run: | + git push origin --atomic --force refs/tags/"${RELEASE_TAG}" refs/tags/"${MAJOR_VERSION_TAG}" + + - name: Prepare partial Changelog + env: + NEW_CHANGELOG: "${{ runner.temp }}/new_changelog.md" + PARTIAL_CHANGELOG: "${{ runner.temp }}/partial_changelog.md" + VERSION: "${{ needs.prepare.outputs.version }}" + run: | + python .github/workflows/script/prepare_changelog.py $NEW_CHANGELOG "$VERSION" > $PARTIAL_CHANGELOG + + echo "::group::Partial CHANGELOG" + cat $PARTIAL_CHANGELOG + echo "::endgroup::" + + - name: Generate token + if: github.event_name == 'workflow_dispatch' + uses: actions/create-github-app-token@v2.1.1 + id: app-token + with: + app-id: ${{ vars.AUTOMATION_APP_ID }} + private-key: ${{ secrets.AUTOMATION_PRIVATE_KEY }} + + - name: Create the rollback release + if: github.event_name == 'workflow_dispatch' + env: + PARTIAL_CHANGELOG: "${{ runner.temp }}/partial_changelog.md" + VERSION: "${{ needs.prepare.outputs.version }}" + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + # Do not mark this release as latest. The most recent CLI release must be marked as latest. + # Set as a draft to give us an opportunity to review the rollback release. + gh release create \ + "$VERSION" \ + --latest=false \ + --draft \ + --title "$VERSION" \ + --notes-file "$PARTIAL_CHANGELOG" + + - name: Create mergeback branch and PR + uses: ./.github/actions/prepare-mergeback-branch + with: + base: "main" + head: "" + branch: "${{ steps.mergeback-branch.outputs.new-branch }}" + version: "${{ needs.prepare.outputs.version }}" + token: "${{ secrets.GITHUB_TOKEN }}" + # Setting this to `true` for non-workflow_dispatch events will + # still push the `branch`, but won't create a corresponding PR + dry-run: "${{ github.event_name != 'workflow_dispatch' }}" + diff --git a/.github/workflows/script/prepare_changelog.py b/.github/workflows/script/prepare_changelog.py index 316936a6db..24a062ce05 100644 --- a/.github/workflows/script/prepare_changelog.py +++ b/.github/workflows/script/prepare_changelog.py @@ -12,7 +12,7 @@ def extract_changelog_snippet(changelog_file, version_tag): output = EMPTY_CHANGELOG else: - with open('CHANGELOG.md', 'r') as f: + with open(changelog_file, 'r') as f: lines = f.readlines() # Include everything up to, but excluding the second heading diff --git a/.github/workflows/script/rollback_changelog.py b/.github/workflows/script/rollback_changelog.py new file mode 100644 index 0000000000..d4515da862 --- /dev/null +++ b/.github/workflows/script/rollback_changelog.py @@ -0,0 +1,62 @@ +import datetime +import os +import sys + +EMPTY_CHANGELOG = """# CodeQL Action Changelog + +""" + +def get_today_string(): + today = datetime.datetime.today() + return '{:%d %b %Y}'.format(today) + +# Include everything up to and after the first heading, +# but not the first heading and body. +def drop_unreleased_section(lines: list[str]): + before_first_section = '' + after_first_section = '' + found_first_section = False + skipped_first_section = False + + for i, line in enumerate(lines): + if line.startswith('## ') and not found_first_section: + found_first_section = True + elif line.startswith('## ') and found_first_section: + skipped_first_section = True + + if not found_first_section: + before_first_section += line + if skipped_first_section: + after_first_section += line + + return (before_first_section, after_first_section) + +def update_changelog(target_version, rollback_version, new_version): + before_first_section = EMPTY_CHANGELOG + after_first_section = '' + + if (os.path.exists('CHANGELOG.md')): + with open('CHANGELOG.md', 'r') as f: + (before_first_section, after_first_section) = drop_unreleased_section(f.readlines()) + + newHeader = f'## {new_version} - {get_today_string()}\n' + + print(before_first_section, end="") + print(newHeader) + print(f"This release rolls back {rollback_version} due to issues with that release. It is identical to {target_version}.\n") + print(after_first_section) + +# We expect three version strings as input: +# +# - target_version: the version that we are re-releasing as `new_version` +# - rollback_version: the version that we are rolling back, typically the one that followed `target_version` +# - new_version: the new version that we are releasing `target_version` as, typically the one that follows `rollback_version` +# +# Example: python3 .github/workflows/script/rollback_changelog.py "1.2.3" "1.2.4" "1.2.5" +if len(sys.argv) < 4: + raise Exception('Expecting argument: target_version rollback_version new_version') + +target_version = sys.argv[1] +rollback_version = sys.argv[2] +new_version = sys.argv[3] +update_changelog(target_version, rollback_version, new_version) diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml index 9dfeeb5364..8701d7122b 100644 --- a/.github/workflows/update-release-branch.yml +++ b/.github/workflows/update-release-branch.yml @@ -14,46 +14,11 @@ on: jobs: prepare: - runs-on: ubuntu-latest - if: github.repository == 'github/codeql-action' - outputs: - version: ${{ steps.versions.outputs.version }} - major_version: ${{ steps.versions.outputs.major_version }} - latest_tag: ${{ steps.versions.outputs.latest_tag }} - backport_source_branch: ${{ steps.branches.outputs.backport_source_branch }} - backport_target_branches: ${{ steps.branches.outputs.backport_target_branches }} + name: "Prepare release" permissions: contents: read - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 # Need full history for calculation of diffs - - uses: ./.github/actions/release-initialise - - name: Get version tags - id: versions - run: | - VERSION="v$(jq '.version' -r 'package.json')" - echo "version=${VERSION}" >> $GITHUB_OUTPUT - MAJOR_VERSION=$(cut -d '.' -f1 <<< "${VERSION}") - echo "major_version=${MAJOR_VERSION}" >> $GITHUB_OUTPUT - LATEST_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | head -1) - echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT - - - id: branches - name: Determine older release branches - uses: ./.github/actions/release-branches - with: - major_version: ${{ steps.versions.outputs.major_version }} - latest_tag: ${{ steps.versions.outputs.latest_tag }} - - - name: debug logging - run: | - echo 'version: ${{ steps.versions.outputs.version }}' - echo 'major_version: ${{ steps.versions.outputs.major_version }}' - echo 'latest_tag: ${{ steps.versions.outputs.latest_tag }}' - echo 'backport_source_branch: ${{ steps.branches.outputs.backport_source_branch }}' - echo 'backport_target_branches: ${{ steps.branches.outputs.backport_target_branches }}' + uses: ./.github/workflows/prepare-release.yml update: timeout-minutes: 45