diff --git a/.github/workflows/actions/changelog/action.yml b/.github/workflows/actions/changelog/action.yml new file mode 100644 index 00000000000..c99bed23450 --- /dev/null +++ b/.github/workflows/actions/changelog/action.yml @@ -0,0 +1,22 @@ +name: Changelog generator +description: Generates and publishes a changelog for the given release version +inputs: + target: + description: Target tag, branch or commit hash for the changelog + required: true + previous: + description: Previous tag, branch or commit hash to start changelog from + required: false + github_token: + description: GitHub token with read/write access to all necessary repositories + required: true + output_file: + description: A file to store resulting changelog markdown + required: false +outputs: + changelog: + description: Changelog contents between the two given versions in Markdown format +runs: + using: 'node20' + main: 'index.js' + diff --git a/.github/workflows/actions/changelog/index.js b/.github/workflows/actions/changelog/index.js new file mode 100644 index 00000000000..9bb200ba208 --- /dev/null +++ b/.github/workflows/actions/changelog/index.js @@ -0,0 +1,319 @@ +import { appendFileSync, writeFileSync } from 'fs'; +import { exec as execCallback } from 'node:child_process'; +import { promisify } from 'node:util'; + +// +// Github Action core utils: logging (notice + debug log levels), must escape +// newlines and percent signs +// +const escapeData = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +const LOG = (msg) => console.log(`::notice::${escapeData(msg)}`); + +// +// Semver utils: parse, compare, sort etc (using official regexp) +// https://regex101.com/r/Ly7O1x/3/ +// +const semverRegExp = + /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +const semverParse = (tag) => { + const m = tag.match(semverRegExp); + if (!m) { + return; + } + const [_, major, minor, patch, prerelease] = m; + return [+major, +minor, +patch, prerelease, tag]; +}; + +// semverCompare takes two parsed semver tags and comparest them more or less +// according to the semver specs +const semverCompare = (a, b) => { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { + return a[i] < b[i] ? 1 : -1; + } + } + if (a[3] !== b[3]) { + return a[3] < b[3] ? 1 : -1; + } + return 0; +}; + +// Using `git tag -l` output find the tag (version) that goes semantically +// right before the given version. This might not work correctly with some +// pre-release versions, which is why it's possible to pass previous version +// into this action explicitly to avoid this step. +const getPreviousVersion = async (version) => { + const exec = promisify(execCallback); + const { stdout } = await exec('git tag -l'); + const prev = stdout + .split('\n') + .map(semverParse) + .filter((tag) => tag) + .sort(semverCompare) + .find((tag) => semverCompare(tag, semverParse(version)) > 0); + if (!prev) { + throw `Could not find previous git tag for ${version}`; + } + return prev[4]; +}; + +// A helper for Github GraphQL API endpoint +const graphql = async (ghtoken, query, variables) => { + const { env } = process; + const results = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ghtoken}`, + }, + body: JSON.stringify({ query, variables }), + }); + const { data } = await results.json(); + return data; +}; + +// Using Github GraphQL API find the timestamp for the given tag/commit hash. +// This is required for PR listing, because Github API only takes date/time as +// a "since" parameter while listing. Currently there is no way to provide two +// "commitish" items and get a list of PRs in between them. +const getCommitishDate = async (name, owner, target) => { + const result = await graphql( + ghtoken, + ` + query getCommitDate($owner: String!, $name: String!, $target: String!) { + repository(owner: $owner, name: $name) { + object(expression: $target) { + ... on Commit { + committedDate + } + } + } + } + `, + { name, owner, target } + ); + return result.repository.object.committedDate; +}; + +// Using Github GraphQL API get a list of PRs between the two "commitish" items. +// This resoves the "since" item's timestamp first and iterates over all PRs +// till "target" using naïve pagination. +const getHistory = async (name, owner, target, sinceDate) => { + LOG(`Fetching ${owner}/${name} PRs since ${sinceDate} till ${target}`); + const query = ` + query findCommitsWithAssociatedPullRequests( + $name: String! + $owner: String! + $target: String! + $sinceDate: GitTimestamp + $cursor: String + ) { + repository(name: $name, owner: $owner) { + object(expression: $target) { + ... on Commit { + history(first: 50, since: $sinceDate, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + associatedPullRequests(first: 1) { + nodes { + title + number + labels(first: 10) { + nodes { + name + } + } + commits(first: 1) { + nodes { + commit { + author { + user { + login + } + } + } + } + } + } + } + } + } + } + } + } + }`; + + let cursor; + let nodes = []; + for (;;) { + const result = await graphql(ghtoken, query, { + name, + owner, + target, + sinceDate, + cursor, + }); + LOG(`GraphQL: ${JSON.stringify(result)}`); + nodes = [...nodes, ...result.repository.object.history.nodes]; + const { hasNextPage, endCursor } = result.repository.object.history.pageInfo; + if (!hasNextPage) { + break; + } + cursor = endCursor; + } + return nodes; +}; + +// The main function for this action: given two "commitish" items it gets a +// list of PRs between them and filters/groups the PRs by category (bugfix, +// feature, deprecation, breaking change and plugin fixes/enhancements). +// +// PR grouping relies on Github labels only, not on the PR contents. +const getChangeLogItems = async (name, owner, sinceDate, to) => { + // check if a node contains a certain label + const hasLabel = ({ labels }, label) => labels.nodes.some(({ name }) => name === label); + // get all the PRs between the two "commitish" items + const history = await getHistory(name, owner, to, sinceDate); + + const items = history.flatMap((node) => { + // discard PRs without a "changelog" label + const changes = node.associatedPullRequests.nodes.filter((PR) => hasLabel(PR, 'add to changelog')); + if (changes.length === 0) { + return []; + } + const item = changes[0]; + const { number, url, labels } = item; + const title = item.title.replace(/^\[[^\]]+\]:?\s*/, ''); + // for changelog PRs try to find a suitable category. + // Note that we can not detect "deprecation notices" like that + // as there is no suitable label yet. + const isBug = /fix/i.test(title) || hasLabel({ labels }, 'type/bug'); + const isBreaking = hasLabel({ labels }, 'breaking change'); + const isPlugin = + hasLabel({ labels }, 'area/grafana/ui') || + hasLabel({ labels }, 'area/grafana/toolkit') || + hasLabel({ labels }, 'area/grafana/runtime'); + const author = item.commits.nodes[0].commit.author.user.login; + return { + repo: name, + number, + title, + author, + isBug, + isPlugin, + isBreaking, + }; + }); + return items; +}; + +// ====================================================== +// GENERATE CHANGELOG +// ====================================================== + +LOG(`Changelog action started`); + +const ghtoken = process.env.GITHUB_TOKEN || process.env.INPUT_GITHUB_TOKEN; +if (!ghtoken) { + throw 'GITHUB_TOKEN is not set and "github_token" input is empty'; +} + +const target = process.argv[2] || process.env.INPUT_TARGET; +LOG(`Target tag/branch/commit: ${target}`); + +const previous = process.argv[3] || process.env.INPUT_PREVIOUS || (await getPreviousVersion(target)); + +LOG(`Previous tag/commit: ${previous}`); + +const sinceDate = await getCommitishDate('grafana', 'grafana', previous); +LOG(`Previous tag/commit timestamp: ${sinceDate}`); + +// Get all changelog items from Grafana OSS +const oss = await getChangeLogItems('grafana', 'grafana', sinceDate, target); +// Get all changelog items from Grafana Enterprise +const entr = await getChangeLogItems('grafana-enterprise', 'grafana', sinceDate, target); + +LOG(`Found OSS PRs: ${oss.length}`); +LOG(`Found Enterprise PRs: ${entr.length}`); + +// Sort PRs and categorise them into sections +const changelog = [...oss, ...entr] + .sort((a, b) => (a.title < b.title ? -1 : 1)) + .reduce( + (changelog, item) => { + if (item.isPlugin) { + changelog.plugins.push(item); + } else if (item.isBug) { + changelog.bugfixes.push(item); + } else if (item.isBreaking) { + changelog.breaking.push(item); + } else { + changelog.features.push(item); + } + return changelog; + }, + { + breaking: [], + plugins: [], + bugfixes: [], + features: [], + } + ); + +// Convert PR numbers to Github links +const pullRequestLink = (n) => `[#${n}](https://github.com/grafana/grafana/pull/${n})`; +// Convert Github user IDs to Github links +const userLink = (u) => `[@${u}](https://github.com/${u})`; + +// Now that we have a changelog - we can render some markdown as an output +const markdown = (changelog) => { + // This convers a list of changelog items into a markdown section with a list of titles/links + const section = (title, items) => + items.length === 0 + ? '' + : `### ${title} + +${items + .map( + (item) => + `- ${item.title.replace(/^([^:]*:)/gm, '**$1**')} ${ + item.repo === 'grafana-enterprise' + ? '(Enterprise)' + : `${pullRequestLink(item.number)}, ${userLink(item.author)}` + }` + ) + .join('\n')} + `; + + // Render all present sections for the given changelog + return `${section('Features and enhancements', changelog.features)} +${section('Bug fixes', changelog.bugfixes)} +${section('Breaking changes', changelog.breaking)} +${section('Plugin development fixes & changes', changelog.plugins)} +`; +}; + +const md = markdown(changelog); + +// Print changelog, mostly for debugging +LOG(`Resulting markdown: ${md}`); + +// Save changelog as an output for this action +if (process.env.GITHUB_OUTPUT) { + LOG(`Output to ${process.env.GITHUB_OUTPUT}`); + appendFileSync(process.env.GITHUB_OUTPUT, `changelog< CHANGELOG.part + + # Check if a version exists in the changelog + if grep -q "" + cat CHANGELOG.part + echo "" + cat CHANGELOG.md + ) > CHANGELOG.tmp + mv CHANGELOG.tmp CHANGELOG.md + fi + + git diff CHANGELOG.md + - name: "Commit changelog changes" + run: git commit --allow-empty -m "Update changelog placeholder" CHANGELOG.md + - name: "git push" + if: ${{ inputs.dry_run }} != true + run: git push + - name: "Create changelog PR" + if: "${{ inputs.backport == '' }}" + run: > + gh pr create \ + $( [ "x${{ inputs.latest }}" == "xtrue" ] && printf %s '-l "release/latest"') \ + --dry-run=${{ inputs.dry_run }} \ + -B "${{ inputs.target }}" \ + --title "Release: ${{ inputs.version }}" \ + --body "Changelog changes for release ${{ inputs.version }}" + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 17a131c0f34..7fc1436a5e4 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -30,7 +30,7 @@ on: type: bool permissions: - content: write + contents: write pull-requests: write jobs: @@ -39,44 +39,103 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'grafana/grafana' steps: + + - name: Generate bot token + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }} + private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }} + - name: Checkout Grafana uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Configure git user run: | git config --local user.name "github-actions[bot]" git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local --add --bool push.autoSetupRemote true + - name: Create branch run: git checkout -b "release/${{ github.run_id }}/${{ inputs.version }}" + - name: Generate changelog - run: git commit --allow-empty -m "Update changelog placeholder" + id: changelog + uses: ./.github/workflows/actions/changelog + with: + github_token: ${{ steps.generate_token.outputs.token }} + target: v${{ inputs.version }} + output_file: changelog_items.md + + - name: Patch CHANGELOG.md + run: | + # Prepare CHANGELOG.md content with version delimiters + ( + echo + echo "# ${{ inputs.version}} ($(date '+%F'))" + echo + cat changelog_items.md + ) > CHANGELOG.part + + # Check if a version exists in the changelog + if grep -q "" + cat CHANGELOG.part + echo "" + cat CHANGELOG.md + ) > CHANGELOG.tmp + mv CHANGELOG.tmp CHANGELOG.md + fi + + rm -f CHANGELOG.part changelog_items.md + + git diff CHANGELOG.md + + - name: Commit CHANGELOG.md changes + run: git commit --allow-empty -m "Update changelog placeholder" CHANGELOG.md + - name: Update package.json versions uses: ./pkg/build/actions/bump-version with: version: ${{ inputs.version }} - - name: add package.json changes + + - name: Add package.json changes run: | git add . git commit -m "Update version to ${{ inputs.version }}" - - name: git push + + - name: Git push if: ${{ inputs.dry_run }} != true run: git push + - name: Create PR without backports if: "${{ inputs.backport == '' }}" run: > gh pr create \ - $( (( ${{ inputs.latest }} == "true" )) && printf %s '-l "release/latest"') \ + $( [ "x${{ inputs.latest }}" == "xtrue" ] && printf %s '-l "release/latest"') \ --dry-run=${{ inputs.dry_run }} \ -B "${{ inputs.target }}" \ --title "Release: ${{ inputs.version }}" \ --body "These code changes must be merged after a release is complete" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create PR with backports if: "${{ inputs.backport != '' }}" run: > gh pr create \ - $( (( ${{ inputs.latest }} == "true" )) && printf %s '-l "release/latest"') \ + $( [ "x${{ inputs.latest }}" == "xtrue" ] && printf %s '-l "release/latest"') \ -l "backport ${{ inputs.backport }}" \ -l "product-approved" \ --dry-run=${{ inputs.dry_run }} \