diff --git a/.github/actions/release/README.md b/.github/actions/release/README.md new file mode 100644 index 0000000..0f93d1d --- /dev/null +++ b/.github/actions/release/README.md @@ -0,0 +1,68 @@ +# `release` action + +Prepares a _draft_ GitHub release from a merged release Pull Request (PR): the +tag is the version in the title of the merge commit, the target is that commit +and the notes are its description. + +## Usage + +```yaml +on: + pull_request: + types: [closed] + branches: [main] + +permissions: {} + +jobs: + draft-release: + if: | + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'releases/') + runs-on: ubuntu-latest + permissions: + # Required to create the draft release. + contents: write + + steps: + # Pin to a commit of `mozilla/addons`. + - uses: mozilla/addons/.github/actions/release@43401a931ebc09f2e511deed766171b0105414bc + with: + repo: ${{ github.repository }} + sha: ${{ github.event.pull_request.merge_commit_sha }} + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +All three inputs are required. Outputs: `version` and `url`. + +## Release process + +A release starts with a PR from a `releases/*` branch, with a single commit +that bumps the version, mentions it in its title and has the release notes as +its description. Versions follow [semantic versioning](https://semver.org/). + +Here is an example for an `npm` package: + +1. Create the release PR (`npm version` takes `minor`, `patch` or `major`, and + `--no-git-tag-version` only updates `package.json` and `package-lock.json`): + + ``` + version=$(npm version minor --no-git-tag-version) + git switch -c "releases/$version" + git add package.json package-lock.json + # Write the release notes in the description of this commit, also the commit + # title might just be "$version" or something more fancy, but it must have + # the version number in it. + git commit --edit --message ":arrow_up: release $version" + git push -u origin "releases/$version" + ``` + +2. Open the PR: `gh pr create --fill` + +3. Get it reviewed, then squash and merge it, keeping the release notes in the + description of the merge commit (GitHub pre-fills them). The draft release + is prepared, but nothing has been released yet! + +4. Open the draft release and click _Publish release_. This creates the tag, + which makes CI publish the package to npm. That allows other workflows to be + triggered as well. diff --git a/.github/actions/release/action.yml b/.github/actions/release/action.yml new file mode 100644 index 0000000..d1a9279 --- /dev/null +++ b/.github/actions/release/action.yml @@ -0,0 +1,90 @@ +name: Draft Release +description: Prepare a draft GitHub release from a merged release pull request + +# Nothing is released until someone publishes the draft. See the README for the +# release process and for a workflow calling this action, which needs +# `contents: write`. + +inputs: + repo: + required: true + description: The name of the repository + sha: + required: true + description: > + The merge commit to release: it is the target of the release, its title + has the version and its description has the release notes. + github_token: + required: true + description: The GitHub token + +outputs: + version: + description: The version of the draft release + value: ${{ steps.release.outputs.version }} + url: + description: The URL of the draft release + value: ${{ steps.release.outputs.url }} + +runs: + using: composite + steps: + - name: Prepare the draft release + id: release + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + GH_REPO: ${{ inputs.repo }} + sha: ${{ inputs.sha }} + run: | + set -o pipefail + + # The response is not piped directly into `jq` so that a failing + # `gh api` call fails this step, instead of looking like a commit + # without a description. + commit=$(gh api "repos/$GH_REPO/commits/$sha") + parents=$(printf '%s' "$commit" | jq '.parents | length') + message=$(printf '%s' "$commit" | jq -r .commit.message) + title=$(printf '%s\n' "$message" | head -n 1) + + # The title is the one of the pull request followed by its number, + # e.g. ":arrow_up: release 1.2.3 (#123)". That number cannot be + # mistaken for the version, as it has no dots. + version=$(printf '%s' "$title" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?' | head -n 1 || true) + + if [ -z "$version" ]; then + echo "::error::no version found in the merge commit title: $title" + exit 1 + fi + + # The notes are the description of the squash-merge commit, i.e., the + # notes someone confirmed when merging, even when the release branch + # ended up with more than one commit. A "real" merge commit has no + # such description, so we let GitHub generate the notes in that case. + notes="" + if [ "$parents" -eq 1 ]; then + notes=$(printf '%s\n' "$message" | sed '1d' | sed '/./,$!d') + else + echo "::warning::the release pull request has not been squash-merged." + fi + + if [ -z "$notes" ]; then + echo "::warning::no release notes found in the merge commit, generating them instead." + gh release create "$version" --draft --target "$sha" --title "$version" --generate-notes + else + printf '%s' "$notes" | gh release create "$version" --draft --target "$sha" --title "$version" --notes-file - + fi + + url=$(gh release view "$version" --json url --jq .url) + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "url=$url" >> "$GITHUB_OUTPUT" + cat "$GITHUB_OUTPUT" + + { + echo "### The draft release for \`$version\` is ready" + echo "" + echo "Nothing has been released yet. Publishing this draft creates the \`$version\` tag, which publishes the package:" + echo "" + echo "$url" + } >> "$GITHUB_STEP_SUMMARY"