diff --git a/.github/scripts/changelog-commits.sh b/.github/scripts/changelog-commits.sh new file mode 100755 index 000000000..d4d460a38 --- /dev/null +++ b/.github/scripts/changelog-commits.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Prints the commits that are new in compared to the release , +# oldest first, one SHA per line. +# +# Usage: changelog-commits.sh +# +# When is an ancestor of , that's simply base..target. When it +# isn't (e.g. base is a 2.11.x release cut from a maintenance branch and +# target is on master), base..target also contains the master commits whose +# fixes were already cherry-picked into the base release. Those are skipped +# when any of the following holds: +# +# 1. The commit has the same patch ID as a commit on the base side +# (a clean cherry-pick). +# 2. Cherry-picking it onto the base would change nothing, but +# cherry-picking it onto the merge base would. That is, the base +# already has the change, and it got there after the branches split. +# This catches clean picks whose lines were changed again later. The +# merge-base condition keeps a revert of something the base never had +# (which would also change nothing) in the list. +# 3. Its subject ends with a PR number, e.g. "(#1234)", and a commit on +# the base side has the identical subject. This catches backports that +# had to be adapted to apply. Subjects without a PR number are never +# matched this way: unrelated commits can share one (for example +# GitHub's "Merge commit from fork" for security advisories). +set -euo pipefail + +base=$(git rev-parse --verify "${1:?usage: $0 }^{commit}") +target=$(git rev-parse --verify "${2:?usage: $0 }^{commit}") + +if git merge-base --is-ancestor "$base" "$target"; then + git rev-list --reverse "$base..$target" + exit 0 +fi + +merge_base=$(git merge-base "$base" "$target") +base_tree=$(git rev-parse "$base^{tree}") +merge_base_tree=$(git rev-parse "$merge_base^{tree}") +backported_subjects=$(git log --format=%s "$merge_base..$base" | grep -E '\(#[0-9]+\)$' || true) + +# picking_changes_nothing +picking_changes_nothing() { + local result + if ! result=$(git merge-tree --write-tree --merge-base="$1^" "$2" "$1" 2>/dev/null); then + return 1 # conflicts, so it would change something + fi + [ "$(head -n 1 <<< "$result")" = "$3" ] +} + +for commit in $(git rev-list --reverse --cherry-pick --right-only "$base...$target"); do + subject=$(git log -1 --format=%s "$commit") + if [[ $subject =~ \(#[0-9]+\)$ ]] && grep -qxF -- "$subject" <<< "$backported_subjects"; then + continue + fi + if picking_changes_nothing "$commit" "$base" "$base_tree" && + ! picking_changes_nothing "$commit" "$merge_base" "$merge_base_tree"; then + continue + fi + echo "$commit" +done diff --git a/.github/scripts/previous-release.sh b/.github/scripts/previous-release.sh new file mode 100755 index 000000000..2f3efedb9 --- /dev/null +++ b/.github/scripts/previous-release.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Prints the tag of the highest published (non-draft) GitHub release whose +# version is lower than the given one, or nothing if there is none. +# +# Usage: previous-release.sh +# +# Release changelogs should cover everything since the last release users +# actually received. "The last tag" is not good enough: +# +# - When a release attempt is abandoned after tagging, the Go module proxy +# has usually cached the tag, so it can't be deleted and reused, and the +# next attempt gets a new patch number. Those abandoned tags never get a +# published release, so they are skipped here. +# - Releases can be cut from maintenance branches (e.g. 2.11), so the +# previous release isn't necessarily an ancestor of the new one. Picking +# by version means 2.12.0 compares against the latest 2.11.x, and a 2.11.x +# released after 2.12.0 compares against the previous 2.11.x. +# +# Requires GH_TOKEN and GITHUB_REPOSITORY in the environment. +set -euo pipefail + +version=${1:?usage: $0 } + +# sort -V orders "2.12.0-beta.1" after "2.12.0", but with the prerelease +# separator replaced by "~" (which sorts before everything, even the end of +# the string) it orders like semver. +sort_key() { + printf '%s' "${1#v}" | sed 's/-/~/' +} + +tags=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" --jq '.[] | select(.draft | not) | .tag_name') + +{ + # The new version itself, marked by an empty tag column. + printf '%s\t\n' "$(sort_key "$version")" + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' <<< "$tags" | while IFS= read -r tag; do + if [ "$tag" != "$version" ]; then + printf '%s\t%s\n' "$(sort_key "$tag")" "$tag" + fi + done +} | sort -t $'\t' -k1,1V | awk -F '\t' '$2 == "" { print prev; exit } { prev = $2 }' diff --git a/.github/scripts/release-notes.sh b/.github/scripts/release-notes.sh new file mode 100755 index 000000000..cdfad192a --- /dev/null +++ b/.github/scripts/release-notes.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Prints GitHub's generated release notes for (What's Changed, New +# Contributors, Full Changelog), compared against , without +# the PRs that already shipped in . +# +# Usage: release-notes.sh [previous-tag] +# +# GitHub's generator lists every PR in previous...tag. When the previous +# release was cut from a maintenance branch, that includes the master PRs +# whose fixes were cherry-picked into it, so those are removed here, using +# the same rules as changelog-commits.sh. Only lines ending in a PR link can +# be removed; anything else is left alone. +# +# Requires GH_TOKEN and GITHUB_REPOSITORY in the environment. +set -euo pipefail + +tag=${1:?usage: $0 [previous-tag]} +target=${2:?usage: $0 [previous-tag]} +previous=${3:-} + +args=(-f "tag_name=$tag" -f "target_commitish=$(git rev-parse --verify "$target^{commit}")") +if [ -n "$previous" ]; then + args+=(-f "previous_tag_name=$previous") +fi +notes=$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" "${args[@]}" --jq .body) + +if [ -z "$previous" ]; then + printf '%s\n' "$notes" + exit 0 +fi + +scripts=$(dirname "$0") +kept=$("$scripts/changelog-commits.sh" "$previous" "$target") +skipped_prs=$(git rev-list "$previous..$target" | { grep -vxF -f <(printf '%s\n' "$kept") || true; } | + xargs -r git log --no-walk --format=%s | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p' | sort -u) + +if [ -z "$skipped_prs" ]; then + printf '%s\n' "$notes" + exit 0 +fi + +pattern="/pull/($(paste -sd '|' <<< "$skipped_prs"))\$" +printf '%s\n' "$notes" | tr -d '\r' | grep -vE -- "$pattern" diff --git a/.github/scripts/release-proposal.js b/.github/scripts/release-proposal.js new file mode 100644 index 000000000..bd3df6745 --- /dev/null +++ b/.github/scripts/release-proposal.js @@ -0,0 +1,121 @@ +// Shared release proposal logic for the release workflows, loaded from +// actions/github-script with require(). Keeping it in one place makes the +// approval tracker and the release job agree on what an approved proposal is. +// +// A proposal is a draft PR labelled "release-proposal", titled exactly +// "Release Proposal: ", whose head commit message carries a +// "Target-Commit: " trailer. The trailer is written by the +// Release Proposal workflow; unlike the PR body, it can't be changed +// without pushing a new commit, which makes earlier approvals stale. +'use strict'; + +const VERSION_RE = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/; +const TARGET_COMMIT_RE = /^Target-Commit: ([0-9a-f]{40})$/m; +const REQUIRED_APPROVALS = 2; + +function proposalTitle(version) { + return `Release Proposal: ${version}`; +} + +function validateVersion(version) { + if (!VERSION_RE.test(version)) { + throw new Error(`invalid release version ${JSON.stringify(version)}`); + } +} + +// parseMaintainers splits the MAINTAINER_LOGINS secret (comma or semicolon +// separated) into lowercased logins. GitHub logins are case-insensitive. +function parseMaintainers(raw) { + return (raw || '') + .split(/[,;]/) + .map(login => login.trim().toLowerCase()) + .filter(login => login.length > 0); +} + +// findOpenProposals returns every open release proposal for the version. +async function findOpenProposals({ github, context, version }) { + validateVersion(version); + const prs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + return prs.filter(pr => + pr.title === proposalTitle(version) && + pr.labels.some(label => label.name === 'release-proposal')); +} + +// targetCommit reads the Target-Commit trailer from the proposal's head commit. +async function targetCommit({ github, context, pr }) { + const { data: commit } = await github.rest.git.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: pr.head.sha, + }); + const match = commit.message.match(TARGET_COMMIT_RE); + if (!match) { + throw new Error(`proposal #${pr.number} head commit ${pr.head.sha} has no Target-Commit trailer`); + } + return match[1]; +} + +// maintainerApprovals returns the maintainers whose latest review approves +// the proposal's current head commit. Plain comments don't change a +// reviewer's verdict, and approvals of an earlier head commit don't count. +async function maintainerApprovals({ github, context, pr, maintainers }) { + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }); + const latest = new Map(); + for (const review of reviews) { + if (!review.user || !['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) { + continue; + } + const login = review.user.login.toLowerCase(); + const previous = latest.get(login); + if (!previous || new Date(review.submitted_at) > new Date(previous.submitted_at)) { + latest.set(login, review); + } + } + return [...latest.entries()] + .filter(([login, review]) => + maintainers.includes(login) && + review.state === 'APPROVED' && + review.commit_id === pr.head.sha) + .map(([, review]) => review.user.login); +} + +// checkProposal finds the single open proposal for the version and returns +// it with its target commit and maintainer approvals. It throws if there +// isn't exactly one proposal or the maintainer list is empty, so callers +// fail closed. +async function checkProposal({ github, context, version, maintainerLogins }) { + const maintainers = parseMaintainers(maintainerLogins); + if (maintainers.length === 0) { + throw new Error('MAINTAINER_LOGINS is empty; cannot count approvals'); + } + const proposals = await findOpenProposals({ github, context, version }); + if (proposals.length !== 1) { + throw new Error(`expected exactly one open PR titled "${proposalTitle(version)}" with the release-proposal label, found ${proposals.length}`); + } + const pr = proposals[0]; + const approvers = await maintainerApprovals({ github, context, pr, maintainers }); + return { + pr, + targetCommit: await targetCommit({ github, context, pr }), + approvers, + approved: approvers.length >= REQUIRED_APPROVALS, + }; +} + +module.exports = { + REQUIRED_APPROVALS, + checkProposal, + findOpenProposals, + proposalTitle, + validateVersion, +}; diff --git a/.github/workflows/auto-release-pr.yml b/.github/workflows/auto-release-pr.yml index d30aa29f2..9ac151836 100644 --- a/.github/workflows/auto-release-pr.yml +++ b/.github/workflows/auto-release-pr.yml @@ -1,9 +1,14 @@ name: Release Proposal Approval Tracker +# pull_request_target (instead of pull_request) runs in the context of the +# base branch, so runs for fork PRs don't sit waiting for workflow approval +# and expire. That's safe here because these jobs never check out or run +# code from the PR: the only checkout is of the base branch, for the shared +# release proposal script. on: pull_request_review: types: [submitted, dismissed] - pull_request: + pull_request_target: types: [labeled, unlabeled, synchronize, closed] permissions: @@ -15,76 +20,59 @@ jobs: check-approvals: name: Track Maintainer Approvals runs-on: ubuntu-latest - # Only run on PRs with release-proposal label - if: contains(github.event.pull_request.labels.*.name, 'release-proposal') && github.event.pull_request.state == 'open' - + # Only run on open release proposals, which are created by the Release + # Proposal workflow from a branch in this repository + if: | + github.repository == 'caddyserver/caddy' && + contains(github.event.pull_request.labels.*.name, 'release-proposal') && + github.event.pull_request.state == 'open' && + github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Checkout release scripts + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + persist-credentials: false + - name: Check approvals and update PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }} with: script: | - const pr = context.payload.pull_request; - + const { checkProposal, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`); + const payloadPR = context.payload.pull_request; + // Extract version from PR title (e.g., "Release Proposal: v1.2.3") - const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/); - const commitMatch = pr.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/); - - if (!versionMatch || !commitMatch) { - console.log('Could not extract version from title or commit from body'); + const versionMatch = payloadPR.title.match(/^Release Proposal: (v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)$/); + if (!versionMatch) { + console.log(`Not a release proposal title: ${payloadPR.title}`); return; } - const version = versionMatch[1]; - const targetCommit = commitMatch[1]; - + + // Same checks as the release workflow runs when the tag is pushed + const { pr, targetCommit, approvers, approved } = await checkProposal({ + github, context, version, maintainerLogins: process.env.MAINTAINER_LOGINS, + }); + if (pr.number !== payloadPR.number) { + core.setFailed(`PR #${payloadPR.number} is not the open proposal for ${version} (#${pr.number} is)`); + return; + } + console.log(`Version: ${version}, Target Commit: ${targetCommit}`); - - // Get all reviews - const reviews = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number - }); - - // Get list of maintainers - const maintainerLoginsRaw = process.env.MAINTAINER_LOGINS || ''; - const maintainerLogins = maintainerLoginsRaw - .split(/[,;]/) - .map(login => login.trim()) - .filter(login => login.length > 0); - - console.log(`Maintainer logins: ${maintainerLogins.join(', ')}`); - - // Get the latest review from each user - const latestReviewsByUser = {}; - reviews.data.forEach(review => { - const username = review.user.login; - if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) { - latestReviewsByUser[username] = review; - } - }); - - // Count approvals from maintainers - const maintainerApprovals = Object.entries(latestReviewsByUser) - .filter(([username, review]) => - maintainerLogins.includes(username) && - review.state === 'APPROVED' - ) - .map(([username, review]) => username); - - const approvalCount = maintainerApprovals.length; - console.log(`Found ${approvalCount} maintainer approvals from: ${maintainerApprovals.join(', ')}`); - + console.log(`Found ${approvers.length} maintainer approvals of ${pr.head.sha} from: ${approvers.join(', ')}`); + // Get current labels const currentLabels = pr.labels.map(label => label.name); const hasApprovedLabel = currentLabels.includes('approved'); const hasAwaitingApprovalLabel = currentLabels.includes('awaiting-approval'); - - if (approvalCount >= 2 && !hasApprovedLabel) { + + if (approved && !hasApprovedLabel) { console.log('✅ Quorum reached! Updating PR...'); - + // Remove awaiting-approval label if present if (hasAwaitingApprovalLabel) { await github.rest.issues.removeLabel({ @@ -94,7 +82,7 @@ jobs: name: 'awaiting-approval' }).catch(e => console.log('Label not found:', e.message)); } - + // Add approved label await github.rest.issues.addLabels({ owner: context.repo.owner, @@ -102,13 +90,13 @@ jobs: issue_number: pr.number, labels: ['approved'] }); - + // Add comment with tagging instructions - const approversList = maintainerApprovals.map(u => `@${u}`).join(', '); + const approversList = approvers.map(u => `@${u}`).join(', '); const commentBody = [ '## ✅ Approval Quorum Reached', '', - `This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`, + `This release proposal has been approved by ${approvers.length} maintainers: ${approversList}`, '', '### Tagging Instructions', '', @@ -123,18 +111,18 @@ jobs: '', 'The release workflow will automatically start when the tag is pushed.' ].join('\n'); - + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, body: commentBody }); - + console.log('Posted tagging instructions'); - } else if (approvalCount < 2 && hasApprovedLabel) { + } else if (!approved && hasApprovedLabel) { console.log('⚠️ Approval count dropped below quorum, removing approved label'); - + // Remove approved label await github.rest.issues.removeLabel({ owner: context.repo.owner, @@ -142,7 +130,7 @@ jobs: issue_number: pr.number, name: 'approved' }).catch(e => console.log('Label not found:', e.message)); - + // Add awaiting-approval label if (!hasAwaitingApprovalLabel) { await github.rest.issues.addLabels({ @@ -153,14 +141,17 @@ jobs: }); } } else { - console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`); + console.log(`⏳ Waiting for more approvals (${approvers.length}/${REQUIRED_APPROVALS} required)`); } + handle-pr-closed: name: Handle PR Closed Without Tag runs-on: ubuntu-latest if: | + github.repository == 'caddyserver/caddy' && contains(github.event.pull_request.labels.*.name, 'release-proposal') && + github.event.pull_request.head.repo.full_name == github.repository && github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released') steps: diff --git a/.github/workflows/release-proposal.yml b/.github/workflows/release-proposal.yml index 8f02f5a6d..768677fee 100644 --- a/.github/workflows/release-proposal.yml +++ b/.github/workflows/release-proposal.yml @@ -36,196 +36,199 @@ jobs: with: fetch-depth: 0 + # Inputs, step outputs and other untrusted text (commit subjects are + # contributor-written PR titles) are only ever passed through env:, never + # interpolated as expressions into run: or script: bodies. - name: Trim and validate inputs id: inputs + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_COMMIT_HASH: ${{ inputs.commit_hash }} run: | # Trim whitespace from inputs - VERSION=$(echo "${{ inputs.version }}" | xargs) - COMMIT_HASH=$(echo "${{ inputs.commit_hash }}" | xargs) - - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT - + VERSION=$(echo "$INPUT_VERSION" | xargs) + COMMIT_HASH=$(echo "$INPUT_COMMIT_HASH" | xargs) + # Validate version format if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)" exit 1 fi - + # Validate commit hash format if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then echo "Error: Commit hash must be a valid SHA (7-40 characters)" exit 1 fi - - # Check if commit exists - if ! git cat-file -e "$COMMIT_HASH"; then - echo "Error: Commit $COMMIT_HASH does not exist" + + # Resolve to the full SHA. This fails if the commit doesn't exist or + # a short SHA is ambiguous, and from here on only the full SHA is used. + if ! FULL_COMMIT_HASH=$(git rev-parse --verify --quiet "${COMMIT_HASH}^{commit}"); then + echo "Error: Commit $COMMIT_HASH does not exist or is ambiguous" exit 1 fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "commit_hash=$FULL_COMMIT_HASH" >> "$GITHUB_OUTPUT" + - name: Check if tag already exists + env: + VERSION: ${{ steps.inputs.outputs.version }} run: | - if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then - echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists" + if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then + echo "Error: Tag $VERSION already exists" exit 1 fi - name: Check for existing proposal PR - id: check_existing uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + VERSION: ${{ steps.inputs.outputs.version }} with: script: | - const version = '${{ steps.inputs.outputs.version }}'; - - // Search for existing open PRs with release-proposal label that match this version - const openPRs = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - sort: 'updated', - direction: 'desc' - }); - - const existingOpenPR = openPRs.data.find(pr => - pr.title.includes(version) && - pr.labels.some(label => label.name === 'release-proposal') - ); - - if (existingOpenPR) { - const hasReleased = existingOpenPR.labels.some(label => label.name === 'released'); - const hasReleaseInProgress = existingOpenPR.labels.some(label => label.name === 'release-in-progress'); - - if (hasReleased || hasReleaseInProgress) { - core.setFailed(`A release for ${version} is already in progress or completed: ${existingOpenPR.html_url}`); + const { findOpenProposals } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`); + const version = process.env.VERSION; + + const [existing] = await findOpenProposals({ github, context, version }); + if (existing) { + const labels = existing.labels.map(label => label.name); + if (labels.includes('released') || labels.includes('release-in-progress')) { + core.setFailed(`A release for ${version} is already in progress or completed: ${existing.html_url}`); } else { - core.setFailed(`An open release proposal already exists for ${version}: ${existingOpenPR.html_url}\n\nPlease use the existing PR or close it first.`); + core.setFailed(`An open release proposal already exists for ${version}: ${existing.html_url}\n\nPlease use the existing PR or close it first.`); } return; } - - // Check for closed PRs with this version that were cancelled - const closedPRs = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'closed', - sort: 'updated', - direction: 'desc' - }); - - const cancelledPR = closedPRs.data.find(pr => - pr.title.includes(version) && - pr.labels.some(label => label.name === 'release-proposal') && - pr.labels.some(label => label.name === 'cancelled') - ); - - if (cancelledPR) { - console.log(`Found previously cancelled proposal for ${version}: ${cancelledPR.html_url}`); - console.log('Creating new proposal to replace cancelled one...'); - } else { - console.log(`No existing proposal found for ${version}, proceeding...`); - } + console.log(`No open proposal found for ${version}, proceeding...`); - name: Generate changelog and create branch id: setup + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.inputs.outputs.version }} + COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }} + REF_NAME: ${{ github.ref_name }} run: | - VERSION="${{ steps.inputs.outputs.version }}" - COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}" - # Create a new branch for the release proposal BRANCH_NAME="release_proposal-$VERSION" git checkout -b "$BRANCH_NAME" - - # Calculate how many commits behind HEAD - COMMITS_BEHIND=$(git rev-list --count ${COMMIT_HASH}..HEAD) - - if [ "$COMMITS_BEHIND" -eq 0 ]; then - BEHIND_INFO="This is the latest commit (HEAD)" + + # Describe where the commit is relative to the branch this workflow + # was run from. The commit may be on a maintenance branch (e.g. 2.11) + # instead, in which case "behind HEAD" would be meaningless. + if git merge-base --is-ancestor "$COMMIT_HASH" HEAD; then + COMMITS_BEHIND=$(git rev-list --count "${COMMIT_HASH}..HEAD") + if [ "$COMMITS_BEHIND" -eq 0 ]; then + BEHIND_INFO="This is the latest commit on $REF_NAME" + else + BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind $REF_NAME**" + fi else - BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**" + COMMIT_BRANCHES=$(git branch -r --contains "$COMMIT_HASH" --format='%(refname:lstrip=3)' | grep -v '^HEAD$' | paste -sd, - | sed 's/,/, /g') + BEHIND_INFO="This commit is not on $REF_NAME; it is on: ${COMMIT_BRANCHES:-no branch}" fi - - echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT - echo "behind_info=$BEHIND_INFO" >> $GITHUB_OUTPUT - - # Get the last tag - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - - if [ -z "$LAST_TAG" ]; then - echo "No previous tag found, generating full changelog" + echo "behind_info=$BEHIND_INFO" >> "$GITHUB_OUTPUT" + + # Base the changelog on the previous *published* release by version, + # not the last tag, and leave out commits that already shipped in it + # as backports. See the scripts for details. + LAST_RELEASE=$(.github/scripts/previous-release.sh "$VERSION") + + if [ -z "$LAST_RELEASE" ]; then + echo "No previous release found, generating full changelog" COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH") else - echo "Generating changelog since $LAST_TAG" - COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH") + echo "Generating changelog since $LAST_RELEASE" + CHANGELOG_SHAS=$(.github/scripts/changelog-commits.sh "$LAST_RELEASE" "$COMMIT_HASH") + COMMITS="" + if [ -n "$CHANGELOG_SHAS" ]; then + COMMITS=$(git log --no-walk=unsorted --pretty=format:"- %s (%h)" --stdin <<< "$CHANGELOG_SHAS") + fi fi - - # Store changelog for PR body - CLEANSED_COMMITS=$(echo "$COMMITS" | sed 's/`/\\`/g') - echo "changelog<> $GITHUB_OUTPUT - echo "$CLEANSED_COMMITS" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - # Create empty commit for the PR + echo "last_release=${LAST_RELEASE:-the beginning}" >> "$GITHUB_OUTPUT" + + # The changelog goes to the next step through a file, never through + # an expression, since commit subjects are untrusted. + printf '%s\n' "$COMMITS" > "$RUNNER_TEMP/changelog.md" + + # Create an empty commit for the PR. The Target-Commit trailer is the + # source of truth for what was approved: the release workflow reads it + # from the proposal's head commit, and only counts approvals of that + # head commit, so it can't be changed after approval. git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git commit --allow-empty -m "Release proposal for $VERSION" - + git commit --allow-empty -m "Release proposal for $VERSION" -m "Target-Commit: $COMMIT_HASH" + # Push the branch git push origin "$BRANCH_NAME" - - echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + + echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" - name: Create release proposal PR id: create_pr uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + VERSION: ${{ steps.inputs.outputs.version }} + COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }} + BRANCH_NAME: ${{ steps.setup.outputs.branch_name }} + BEHIND_INFO: ${{ steps.setup.outputs.behind_info }} + LAST_RELEASE: ${{ steps.setup.outputs.last_release }} with: script: | - const changelog = `${{ steps.setup.outputs.changelog }}`; - + const fs = require('fs'); + const { proposalTitle, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`); + const { VERSION, COMMIT_HASH, BRANCH_NAME, BEHIND_INFO, LAST_RELEASE, RUNNER_TEMP } = process.env; + const changelog = fs.readFileSync(`${RUNNER_TEMP}/changelog.md`, 'utf8').trimEnd(); + + const body = [ + `## Release Proposal: ${VERSION}`, + '', + `**Target Commit:** \`${COMMIT_HASH}\``, + `**Requested by:** @${context.actor}`, + `**Commit Status:** ${BEHIND_INFO}`, + '', + `This PR proposes creating release tag \`${VERSION}\` at commit \`${COMMIT_HASH}\`.`, + '', + '### Approval Process', + '', + `This PR requires **approval from ${REQUIRED_APPROVALS}+ maintainers** before the tag can be created.`, + 'Approvals only count for the PR\'s current head commit, whose `Target-Commit` trailer records the commit to tag.', + '', + '### What happens next?', + '', + '1. Maintainers review this proposal', + `2. When ${REQUIRED_APPROVALS}+ maintainer approvals are received, an automated workflow will post tagging instructions`, + '3. A maintainer manually creates and pushes the signed tag', + '4. The release workflow is triggered automatically by the tag push, and checks the approvals again', + '5. Upon release completion, this PR is closed and the branch is deleted', + '', + `### Changes Since ${LAST_RELEASE}`, + '', + changelog, + '', + '### Release Checklist', + '', + '- [ ] All tests pass', + '- [ ] Security review completed', + '- [ ] Documentation updated', + '- [ ] Breaking changes documented', + '- [ ] Release notes drafted', + '', + '---', + '', + '**Note:** Tag creation is manual and requires a signed tag from a maintainer.', + ].join('\n'); + const pr = await github.rest.pulls.create({ owner: context.repo.owner, repo: context.repo.repo, - title: `Release Proposal: ${{ steps.inputs.outputs.version }}`, - head: '${{ steps.setup.outputs.branch_name }}', + title: proposalTitle(VERSION), + head: BRANCH_NAME, base: 'master', - body: `## Release Proposal: ${{ steps.inputs.outputs.version }} - - **Target Commit:** \`${{ steps.inputs.outputs.commit_hash }}\` - **Requested by:** @${{ github.actor }} - **Commit Status:** ${{ steps.setup.outputs.behind_info }} - - This PR proposes creating release tag \`${{ steps.inputs.outputs.version }}\` at commit \`${{ steps.inputs.outputs.commit_hash }}\`. - - ### Approval Process - - This PR requires **approval from 2+ maintainers** before the tag can be created. - - ### What happens next? - - 1. Maintainers review this proposal - 2. When 2+ maintainer approvals are received, an automated workflow will post tagging instructions - 3. A maintainer manually creates and pushes the signed tag - 4. The release workflow is triggered automatically by the tag push - 5. Upon release completion, this PR is closed and the branch is deleted - - ### Changes Since Last Release - - ${changelog} - - ### Release Checklist - - - [ ] All tests pass - - [ ] Security review completed - - [ ] Documentation updated - - [ ] Breaking changes documented - - [ ] Release notes drafted - - --- - - **Note:** Tag creation is manual and requires a signed tag from a maintainer.`, + body, draft: true }); - + // Add labels await github.rest.issues.addLabels({ owner: context.repo.owner, @@ -233,18 +236,25 @@ jobs: issue_number: pr.data.number, labels: ['release-proposal', 'awaiting-approval'] }); - + console.log(`Created PR: ${pr.data.html_url}`); - + return { number: pr.data.number, url: pr.data.html_url }; result-encoding: json - name: Post summary + env: + VERSION: ${{ steps.inputs.outputs.version }} + COMMIT_HASH: ${{ steps.inputs.outputs.commit_hash }} + BEHIND_INFO: ${{ steps.setup.outputs.behind_info }} + PR_URL: ${{ fromJson(steps.create_pr.outputs.result).url }} run: | - echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY - echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY - echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY + { + echo "## Release Proposal PR Created! 🚀" + echo "" + echo "Version: **$VERSION**" + echo "Commit: **$COMMIT_HASH**" + echo "Status: $BEHIND_INFO" + echo "" + echo "PR: $PR_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3eb30982..b0dbe018d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,13 +20,26 @@ jobs: contents: write pull-requests: write issues: write - + outputs: verification_passed: ${{ steps.verify.outputs.passed }} tag_version: ${{ steps.info.outputs.version }} proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }} - + + # Values from the event, step outputs and API results are only ever + # passed through env:, never interpolated as expressions into run: or + # script: bodies. steps: + # Validate the tag name before anything else uses it. Git allows + # characters like $, ( and ; in tag names that match the v*.*.* trigger, + # and the tag name ends up in shell commands (including GoReleaser hooks). + - name: Validate tag name + run: | + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag" + exit 1 + fi + - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -45,8 +58,8 @@ jobs: - name: Get tag info id: info run: | - echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 - name: Print Go version and environment @@ -80,61 +93,62 @@ jobs: id: verify env: signing_keys: ${{ secrets.SIGNING_KEYS }} + TAG: ${{ steps.vars.outputs.version_tag }} run: | # Read the string into an array, splitting by IFS IFS=";" read -ra keys_collection <<< "$signing_keys" - + # ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-the-runner-context - touch "${{ runner.temp }}/allowed_signers" + touch "$RUNNER_TEMP/allowed_signers" # Iterate and print the split elements for item in "${keys_collection[@]}"; do - + # trim leading whitespaces item="${item##*( )}" # trim trailing whitespaces item="${item%%*( )}" - + IFS=" " read -ra key_components <<< "$item" # git wants it in format: email address, type, public key # ssh has it in format: type, public key, email address - echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "${{ runner.temp }}/allowed_signers" + echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "$RUNNER_TEMP/allowed_signers" done - git config set --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers" + git config set --global gpg.ssh.allowedSignersFile "$RUNNER_TEMP/allowed_signers" + + echo "Verifying the tag: $TAG" - echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}" - # Verify the tag is signed - if ! git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1; then + if ! git verify-tag -v "$TAG" 2>&1; then echo "❌ Tag verification failed!" echo "passed=false" >> $GITHUB_OUTPUT - git push --delete origin "${{ steps.vars.outputs.version_tag }}" + git push --delete origin "$TAG" exit 1 fi # Run it again to capture the output - git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1 | tee /tmp/verify-output.txt; + git verify-tag -v "$TAG" 2>&1 | tee /tmp/verify-output.txt; # SSH verification output typically includes the key fingerprint # Use GNU grep with Perl regex for cleaner extraction (Linux environment) KEY_SHA256=$(grep -oP "SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "") - + if [ -z "$KEY_SHA256" ]; then # Try alternative pattern with "key" prefix KEY_SHA256=$(grep -oP "key SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "") fi - + if [ -z "$KEY_SHA256" ]; then # Fallback: extract any base64-like string (40+ chars) KEY_SHA256=$(grep -oP '[A-Za-z0-9+/]{40,}=?' /tmp/verify-output.txt | head -1 || echo "") fi - + if [ -z "$KEY_SHA256" ]; then echo "Somehow could not extract SSH key fingerprint from git verify-tag output" echo "Cancelling flow and deleting tag" echo "passed=false" >> $GITHUB_OUTPUT - git push --delete origin "${{ steps.vars.outputs.version_tag }}" + git push --delete origin "$TAG" exit 1 fi @@ -142,189 +156,136 @@ jobs: echo "passed=true" >> $GITHUB_OUTPUT echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT + # Fails closed: there must be exactly one open proposal for this exact + # version, with enough maintainer approvals of its current head commit. + # The tag is left in place on failure, so the job can be re-run once + # the proposal is approved. - name: Find related release proposal id: find_proposal uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + TAG: ${{ steps.vars.outputs.version_tag }} + MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }} with: script: | - const version = '${{ steps.vars.outputs.version_tag }}'; - - // Search for PRs with release-proposal label that match this version - const prs = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', // Changed to 'all' to find both open and closed PRs - sort: 'updated', - direction: 'desc' + const { checkProposal, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`); + + const { pr, targetCommit, approvers, approved } = await checkProposal({ + github, context, version: process.env.TAG, maintainerLogins: process.env.MAINTAINER_LOGINS, }); - - // Find the most recent PR for this version - const proposal = prs.data.find(pr => - pr.title.includes(version) && - pr.labels.some(label => label.name === 'release-proposal') - ); - - if (!proposal) { - console.log(`⚠️ No release proposal PR found for ${version}`); - console.log('This might be a hotfix or emergency release'); - return { number: null, approved: true, approvals: 0, proposedCommit: null }; + console.log(`Found proposal PR #${pr.number}, for commit ${targetCommit}`); + console.log(`Maintainer approvals of ${pr.head.sha}: ${approvers.join(', ') || 'none'}`); + + if (!approved) { + core.setFailed(`Proposal #${pr.number} has ${approvers.length} maintainer approvals of its current head commit; ${REQUIRED_APPROVALS} are required`); + return; } - - console.log(`Found proposal PR #${proposal.number} for version ${version}`); - - // Extract commit hash from PR body - const commitMatch = proposal.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/); - const proposedCommit = commitMatch ? commitMatch[1] : null; - - if (proposedCommit) { - console.log(`Proposal was for commit: ${proposedCommit}`); - } else { - console.log('⚠️ No target commit hash found in PR body'); - } - - // Get PR reviews to extract approvers - let approvers = 'Validated by automation'; - let approvalCount = 2; // Minimum required - - try { - const reviews = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: proposal.number - }); - - // Get latest review per user and filter for approvals - const latestReviewsByUser = {}; - reviews.data.forEach(review => { - const username = review.user.login; - if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) { - latestReviewsByUser[username] = review; - } - }); - - const approvalReviews = Object.values(latestReviewsByUser).filter(review => - review.state === 'APPROVED' - ); - - if (approvalReviews.length > 0) { - approvers = approvalReviews.map(r => '@' + r.user.login).join(', '); - approvalCount = approvalReviews.length; - console.log(`Found ${approvalCount} approvals from: ${approvers}`); - } - } catch (error) { - console.log(`Could not fetch reviews: ${error.message}`); - } - + return { - number: proposal.number, - approved: true, - approvals: approvalCount, - approvers: approvers, - proposedCommit: proposedCommit + number: pr.number, + approvals: approvers.length, + approvers: approvers.map(login => `@${login}`).join(', '), + proposedCommit: targetCommit }; result-encoding: json - name: Verify proposal commit + env: + PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }} + CURRENT_COMMIT: ${{ steps.info.outputs.sha }} + TAG: ${{ steps.vars.outputs.version_tag }} run: | - APPROVALS='${{ steps.find_proposal.outputs.result }}' - - # Parse JSON - PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit') - CURRENT_COMMIT="${{ steps.info.outputs.sha }}" - echo "Proposed commit: $PROPOSED_COMMIT" echo "Current commit: $CURRENT_COMMIT" - - # Check if commits match (if proposal had a target commit) - if [ "$PROPOSED_COMMIT" != "null" ] && [ -n "$PROPOSED_COMMIT" ]; then - # Normalize both commits to full SHA for comparison - PROPOSED_FULL=$(git rev-parse "$PROPOSED_COMMIT" 2>/dev/null || echo "") - CURRENT_FULL=$(git rev-parse "$CURRENT_COMMIT" 2>/dev/null || echo "") - - if [ -z "$PROPOSED_FULL" ]; then - echo "⚠️ Could not resolve proposed commit: $PROPOSED_COMMIT" - elif [ "$PROPOSED_FULL" != "$CURRENT_FULL" ]; then - echo "❌ Commit mismatch!" - echo "The tag points to commit $CURRENT_FULL but the proposal was for $PROPOSED_FULL" - echo "This indicates an error in tag creation." - # Delete the tag remotely - git push --delete origin "${{ steps.vars.outputs.version_tag }}" - echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted" - exit 1 - else - echo "✅ Commit hash matches proposal" - fi - else - echo "⚠️ No target commit found in proposal (might be legacy release)" + + # Both are full SHAs, so compare them exactly + if [[ ! "$PROPOSED_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "❌ Proposal has no valid target commit: $PROPOSED_COMMIT" + exit 1 fi - - echo "✅ Tag verification completed" + if [ "$PROPOSED_COMMIT" != "$CURRENT_COMMIT" ]; then + echo "❌ Commit mismatch!" + echo "The tag points to commit $CURRENT_COMMIT but the proposal was for $PROPOSED_COMMIT" + echo "This indicates an error in tag creation." + # Delete the tag remotely + git push --delete origin "$TAG" + echo "Tag $TAG has been deleted" + exit 1 + fi + + echo "✅ Commit hash matches proposal" - name: Update release proposal PR - if: fromJson(steps.find_proposal.outputs.result).number != null uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RESULT: ${{ steps.find_proposal.outputs.result }} + TAG: ${{ steps.info.outputs.version }} + KEY_ID: ${{ steps.verify.outputs.key_id }} + SHA: ${{ steps.info.outputs.sha }} with: script: | - const result = ${{ steps.find_proposal.outputs.result }}; - - if (result.number) { - // Add in-progress label - await github.rest.issues.addLabels({ + const result = JSON.parse(process.env.RESULT); + + // Add in-progress label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: result.number, + labels: ['release-in-progress'] + }); + + // Remove approved label if present + try { + await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: result.number, - labels: ['release-in-progress'] - }); - - // Remove approved label if present - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: result.number, - name: 'approved' - }); - } catch (e) { - console.log('Approved label not found:', e.message); - } - - const commentBody = [ - '## 🚀 Release Workflow Started', - '', - '- **Tag:** ${{ steps.info.outputs.version }}', - '- **Signed by key:** ${{ steps.verify.outputs.key_id }}', - '- **Commit:** ${{ steps.info.outputs.sha }}', - '- **Approved by:** ' + result.approvers, - '', - 'Release workflow is now running. This PR will be updated when the release is published.' - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: result.number, - body: commentBody + name: 'approved' }); + } catch (e) { + console.log('Approved label not found:', e.message); } + const commentBody = [ + '## 🚀 Release Workflow Started', + '', + `- **Tag:** ${process.env.TAG}`, + `- **Signed by key:** ${process.env.KEY_ID}`, + `- **Commit:** ${process.env.SHA}`, + `- **Approved by:** ${result.approvers}`, + '', + 'Release workflow is now running. This PR will be updated when the release is published.' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: result.number, + body: commentBody + }); + - name: Summary + env: + PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }} + APPROVERS: ${{ fromJson(steps.find_proposal.outputs.result).approvers }} + TAG: ${{ steps.info.outputs.version }} + SHA: ${{ steps.info.outputs.sha }} + KEY_ID: ${{ steps.verify.outputs.key_id }} run: | - APPROVALS='${{ steps.find_proposal.outputs.result }}' - PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit // "N/A"') - APPROVERS=$(echo "$APPROVALS" | jq -r '.approvers // "N/A"') - - echo "## Tag Verification Summary 🔐" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Tag:** ${{ steps.info.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY - echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY - echo "- **Signature:** ✅ Verified" >> $GITHUB_STEP_SUMMARY - echo "- **Signed by:** ${{ steps.verify.outputs.key_id }}" >> $GITHUB_STEP_SUMMARY - echo "- **Approvals:** ✅ Sufficient" >> $GITHUB_STEP_SUMMARY - echo "- **Approved by:** $APPROVERS" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Proceeding with release build..." >> $GITHUB_STEP_SUMMARY - + { + echo "## Tag Verification Summary 🔐" + echo "" + echo "- **Tag:** $TAG" + echo "- **Commit:** $SHA" + echo "- **Proposed Commit:** $PROPOSED_COMMIT" + echo "- **Signature:** ✅ Verified" + echo "- **Signed by:** $KEY_ID" + echo "- **Approvals:** ✅ Sufficient" + echo "- **Approved by:** $APPROVERS" + echo "" + echo "Proceeding with release build..." + } >> "$GITHUB_STEP_SUMMARY" + release: name: Release needs: verify-tag @@ -354,6 +315,15 @@ jobs: pull-requests: write steps: + # Same check as in verify-tag: the tag name ends up in GoReleaser's + # shell hooks via $TAG. + - name: Validate tag name + run: | + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag" + exit 1 + fi + - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: @@ -426,12 +396,28 @@ jobs: run: | go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest xcaddy version + # Generate the draft's notes in the same format as GitHub's "Generate + # release notes" button, compared against the previous *published* + # release by version (not the previous tag, which may be an abandoned + # release attempt), and without PRs that already shipped in it as + # backports. GoReleaser's own changelog can do neither. + - name: Generate release notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.vars.outputs.version_tag }} + NOTES_FILE: ${{ runner.temp }}/release-notes.md + run: | + PREVIOUS_RELEASE=$(.github/scripts/previous-release.sh "$TAG") + echo "Previous published release: ${PREVIOUS_RELEASE:-none}" + .github/scripts/release-notes.sh "$TAG" "$TAG" "$PREVIOUS_RELEASE" > "$NOTES_FILE" + cat "$NOTES_FILE" + # GoReleaser will take care of publishing those artifacts into the release - name: Run GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: version: latest - args: release --clean --timeout 60m + args: release --clean --timeout 60m --release-notes ${{ runner.temp }}/release-notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.vars.outputs.version_tag }} @@ -497,9 +483,11 @@ jobs: - name: Update release proposal PR if: needs.verify-tag.outputs.proposal_issue_number != '' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PROPOSAL_NUMBER: ${{ needs.verify-tag.outputs.proposal_issue_number }} with: script: | - const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}'); + const prNumber = parseInt(process.env.PROPOSAL_NUMBER, 10); if (prNumber) { // Get PR details to find the branch diff --git a/.goreleaser.yml b/.goreleaser.yml index 98930db19..23aa95352 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -203,6 +203,8 @@ release: draft: true prerelease: auto +# Only used for local runs: the release workflow passes --release-notes +# generated by .github/scripts/release-notes.sh, which skips this. changelog: sort: asc filters: