Skip to content

Teach the standard the github-action shape (D20) (#37) #22

Teach the standard the github-action shape (D20) (#37)

Teach the standard the github-action shape (D20) (#37) #22

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();