Create release PR #6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Create release PR | |
| # Cutting a release is one deliberate act by a person, and everything after it is automatic. This | |
| # workflow only proposes: it writes the version and the notes and opens a pull request. Merging that | |
| # pull request is what publishes, so the release itself goes through review like anything else. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| bump: | |
| description: Version bump | |
| required: true | |
| type: choice | |
| options: [patch, minor, major] | |
| dry_run: | |
| description: Show the version and notes without opening a PR | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: openbot-release-pr | |
| cancel-in-progress: false | |
| jobs: | |
| create: | |
| name: create | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| # Two open release PRs would each carry a version computed before the other existed, and | |
| # whichever merged second would publish notes that skip a release. | |
| - name: Refuse a second open release PR | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const { data: pulls } = await github.rest.pulls.list({ owner, repo, state: "open" }); | |
| const open = pulls.filter((pull) => pull.head.ref.startsWith("release/publish/")); | |
| if (open.length > 0) { | |
| core.setFailed(`A release PR is already open: ${open.map((p) => p.html_url).join(", ")}`); | |
| } | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| # The whole history, because the previous tag is what says where these notes start. | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 | |
| with: | |
| bun-version: 1.3.14 | |
| - id: prepare | |
| name: Write the version and promote the notes | |
| env: | |
| BUMP: ${{ inputs.bump }} | |
| run: | | |
| set -euo pipefail | |
| current=$(bun -e 'console.log(require("./package.json").version)') | |
| # The version in package.json must be one that was actually released, or the bump is | |
| # computed from a number nobody published. The exception is the first release, where there | |
| # is no tag to find yet. | |
| if [ -n "$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --merged HEAD)" ]; then | |
| git rev-parse --verify "refs/tags/v$current" >/dev/null 2>&1 || { | |
| echo "::error::package.json says $current but v$current was never tagged." | |
| exit 1 | |
| } | |
| fi | |
| # CHANGELOG.md is written by hand, for the person deciding whether to upgrade. This | |
| # promotes what is already there; it never generates notes from commit subjects, because a | |
| # commit subject is written for the person reading the diff. | |
| grep -q '^## Unreleased$' CHANGELOG.md || { | |
| echo "::error::CHANGELOG.md has no '## Unreleased' section." | |
| exit 1 | |
| } | |
| unreleased=$(awk '/^## Unreleased$/{found=1; next} /^## /{found=0} found' CHANGELOG.md | grep -c '[^[:space:]]' || true) | |
| if [ "$unreleased" -eq 0 ]; then | |
| echo "::error::Nothing under '## Unreleased'. A release nobody can describe is not a release." | |
| exit 1 | |
| fi | |
| bun -e ' | |
| const fs = require("fs"); | |
| // `bun -e` passes argv as [bun, ...args], so the argument is the last element. Reading | |
| // it positionally from the front silently yields undefined and every bump becomes a patch. | |
| const bump = process.argv.at(-1); | |
| const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); | |
| const [major, minor, patch] = pkg.version.split(".").map(Number); | |
| const next = | |
| bump === "major" ? [major + 1, 0, 0] : | |
| bump === "minor" ? [major, minor + 1, 0] : | |
| [major, minor, patch + 1]; | |
| pkg.version = next.join("."); | |
| fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`); | |
| console.log(pkg.version); | |
| ' -- "$BUMP" > /tmp/version | |
| version=$(cat /tmp/version) | |
| # Unreleased becomes the version, and a fresh empty Unreleased takes its place so the next | |
| # change has somewhere to go without anyone hand-editing a heading. | |
| bun -e ' | |
| const fs = require("fs"); | |
| const version = process.argv[process.argv.length - 1]; | |
| const text = fs.readFileSync("CHANGELOG.md", "utf8"); | |
| fs.writeFileSync( | |
| "CHANGELOG.md", | |
| text.replace(/^## Unreleased$/m, `## Unreleased\n\n## ${version}`), | |
| ); | |
| ' -- "$version" | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "## Release v$version" | |
| echo | |
| echo "Merging this publishes the image, tags the commit and creates the GitHub Release." | |
| echo | |
| echo "Before merging:" | |
| echo "- [ ] The notes below describe what a deployment does differently" | |
| echo "- [ ] The smoke journey passed against a licensed deployment, and the result is" | |
| echo " pasted in a comment: \`bash scripts/start.sh && bun run test:smoke\`" | |
| echo | |
| echo "Merging runs the full suite against this commit before it builds, so there is" | |
| echo "nothing to check about CI here. The journey is the part CI cannot do: it needs a" | |
| echo "licence, and a licence belongs to the machine it was issued for." | |
| echo | |
| echo '```' | |
| awk -v v="## $version" '$0==v{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md | |
| echo '```' | |
| } > /tmp/pr-body.md | |
| - name: Preview | |
| if: inputs.dry_run | |
| run: | | |
| git --no-pager diff -- package.json CHANGELOG.md | |
| cat /tmp/pr-body.md | |
| # A pull request opened by a workflow does not trigger the pull_request workflows, so the | |
| # release PR arrives without its own checks. That is deliberate rather than tolerated: the | |
| # publish path runs the full suite against the release commit before it builds anything, which | |
| # gates the release itself instead of the proposal for one. | |
| - name: Open the release PR | |
| if: '!inputs.dry_run' | |
| uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| branch: release/publish/v${{ steps.prepare.outputs.version }} | |
| delete-branch: true | |
| labels: release | |
| commit-message: "Release v${{ steps.prepare.outputs.version }}" | |
| title: "Release v${{ steps.prepare.outputs.version }}" | |
| body-path: /tmp/pr-body.md |