261 lines
11 KiB
YAML
261 lines
11 KiB
YAML
name: Release Proposal
|
|
|
|
# This workflow creates a release proposal as a PR that requires approval from maintainers
|
|
# Triggered manually by maintainers when ready to prepare a release
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: 'Version to release (e.g., v2.8.0)'
|
|
required: true
|
|
type: string
|
|
commit_hash:
|
|
description: 'Commit hash to release from'
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
create-proposal:
|
|
name: Create Release Proposal
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
issues: write
|
|
|
|
steps:
|
|
- name: Harden the runner (Audit all outbound calls)
|
|
uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
|
|
with:
|
|
egress-policy: audit
|
|
- name: Checkout code
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
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 "$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
|
|
|
|
# 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 "refs/tags/$VERSION" >/dev/null 2>&1; then
|
|
echo "Error: Tag $VERSION already exists"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Check for existing proposal PR
|
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
env:
|
|
VERSION: ${{ steps.inputs.outputs.version }}
|
|
with:
|
|
script: |
|
|
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}: ${existing.html_url}\n\nPlease use the existing PR or close it first.`);
|
|
}
|
|
return;
|
|
}
|
|
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: |
|
|
# Create a new branch for the release proposal
|
|
BRANCH_NAME="release_proposal-$VERSION"
|
|
git checkout -b "$BRANCH_NAME"
|
|
|
|
# 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
|
|
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 "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_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
|
|
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" -m "Target-Commit: $COMMIT_HASH"
|
|
|
|
# Push the branch
|
|
git push origin "$BRANCH_NAME"
|
|
|
|
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 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: proposalTitle(VERSION),
|
|
head: BRANCH_NAME,
|
|
base: 'master',
|
|
body,
|
|
draft: true
|
|
});
|
|
|
|
// Add labels
|
|
await github.rest.issues.addLabels({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
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! 🚀"
|
|
echo ""
|
|
echo "Version: **$VERSION**"
|
|
echo "Commit: **$COMMIT_HASH**"
|
|
echo "Status: $BEHIND_INFO"
|
|
echo ""
|
|
echo "PR: $PR_URL"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|