Teach the standard the github-action shape (D20) (#37) #22
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: Publish release (automatic) | |
| # repo-infra: release-publish v2 | |
| # | |
| # Half two. Merging the release pull request lands the rolled CHANGES.md on main, | |
| # and that merge triggers this. | |
| # | |
| # This file is assembled, not copied: this frame, then one job block per publish | |
| # add-on the repository installs, then the finalize job whose `needs:` list is | |
| # generated from those blocks. That generated list is the reason the core never | |
| # has to know which add-ons exist. | |
| # | |
| # There is deliberately no workflow_dispatch. Publishing should be a consequence | |
| # of merging a release pull request, never something started from a dropdown. | |
| # With main protected against direct pushes, that leaves exactly one route to a | |
| # release. Recovery still does not need a manual trigger, but it needs the right | |
| # button: `publish` is idempotent by tag presence, so once the tag exists a | |
| # whole-workflow re-run finds it, sets no outputs, and skips every add-on and | |
| # `finalize` -- the release stays a draft forever. Use the Actions UI's | |
| # **Re-run failed jobs** instead: it keeps the successful job's outputs, so the | |
| # jobs that failed run again against them and finish what the first attempt | |
| # would have. | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - CHANGES.md | |
| concurrency: | |
| group: release-publish | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| jobs: | |
| publish: | |
| name: Tag and publish | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| outputs: | |
| version: ${{ steps.publish.outputs.version }} | |
| tag: ${{ steps.publish.outputs.tag }} | |
| release_id: ${{ steps.publish.outputs.release_id }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22 | |
| - name: Tag and create the draft release | |
| id: publish | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const ws = process.env.GITHUB_WORKSPACE; | |
| const lib = `${ws}/.github/workflows/lib`; | |
| const changesLib = require(`${lib}/changes.js`); | |
| const bumpLib = require(`${lib}/bump.js`); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const fileIO = { | |
| read: (p) => fs.readFileSync(path.join(ws, p), 'utf8'), | |
| write: () => { throw new Error('the publisher never writes'); }, | |
| }; | |
| const config = JSON.parse(fileIO.read('.github/repo-infra.json')); | |
| // CHANGES.md is the source of truth. It is the only file every | |
| // repository has, and it changes on exactly one occasion. | |
| const release = changesLib.latestRelease(fileIO.read('CHANGES.md')); | |
| if (!release) { | |
| core.notice('CHANGES.md has no released version yet - nothing to do.'); | |
| return; | |
| } | |
| const version = release.version; | |
| const tag = `v${version}`; | |
| // Every version file is a derived copy. If one disagrees, the release | |
| // pull request half-applied, and tagging now would repeat the failure | |
| // that shipped an inconsistent v0.1.1 elsewhere. | |
| const stale = config.version_files.filter( | |
| (spec) => !bumpLib.verifyFile(spec, version, fileIO) | |
| ); | |
| if (stale.length > 0) { | |
| core.setFailed( | |
| `CHANGES.md says ${version} but these disagree: ` | |
| + stale.map((s) => s.path).join(', ') | |
| ); | |
| return; | |
| } | |
| // Idempotent: an ordinary pull request that edits [Unreleased] also | |
| // triggers this workflow, finds the tag already present, and stops. | |
| try { | |
| await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` }); | |
| core.notice(`${tag} is already tagged - nothing to do.`); | |
| return; | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| } | |
| // Annotated, not a bare createRef. A bare ref makes a lightweight tag, | |
| // and `git describe` prefers annotated ones; the existing repositories | |
| // have annotated tags and a silent switch would change local tooling | |
| // behaviour for no reason. | |
| const makeTag = async (name, message) => { | |
| const { data: object } = await github.rest.git.createTag({ | |
| owner, repo, tag: name, message, object: context.sha, type: 'commit', | |
| }); | |
| return object.sha; | |
| }; | |
| await github.rest.git.createRef({ | |
| owner, repo, ref: `refs/tags/${tag}`, | |
| sha: await makeTag(tag, `Release ${tag}`), | |
| }); | |
| core.notice(`Tagged ${tag}.`); | |
| if (config.moving_major_tag) { | |
| const major = `v${version.split('.')[0]}`; | |
| const sha = await makeTag(major, `Update ${major} to ${tag}`); | |
| try { | |
| await github.rest.git.updateRef({ | |
| owner, repo, ref: `tags/${major}`, sha, force: true, | |
| }); | |
| } catch (error) { | |
| if (error.status !== 422) throw error; | |
| await github.rest.git.createRef({ | |
| owner, repo, ref: `refs/tags/${major}`, sha, | |
| }); | |
| } | |
| core.notice(`Moved ${major} to ${tag}.`); | |
| } | |
| // Draft, so publish add-ons (spec 2) can attach artifacts before | |
| // anyone sees the release. The finalize job below publishes it. | |
| const { data: created } = await github.rest.repos.createRelease({ | |
| owner, repo, | |
| tag_name: tag, | |
| name: tag, | |
| body: changesLib.notesFor(fileIO.read('CHANGES.md'), version), | |
| draft: true, | |
| prerelease: false, | |
| make_latest: 'true', | |
| }); | |
| core.setOutput('version', version); | |
| core.setOutput('tag', tag); | |
| core.setOutput('release_id', String(created.id)); | |
| finalize: | |
| name: Publish the release | |
| # Generated by the assembler: `publish`, then every add-on job installed in | |
| # this repository. An add-on that fails leaves the release a draft, which is | |
| # the correct outcome -- a release missing its artifacts must not be visible. | |
| needs: [publish] | |
| if: needs.publish.outputs.release_id != '' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const { data: released } = await github.rest.repos.updateRelease({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| release_id: Number('${{ needs.publish.outputs.release_id }}'), | |
| draft: false, | |
| }); | |
| core.notice(`Published ${released.html_url}`); | |
| await core.summary | |
| .addHeading(`Released ${{ needs.publish.outputs.tag }}`) | |
| .addLink('Release', released.html_url) | |
| .write(); |