caddy/.github/workflows/release-proposal.yml

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"