Cut a release once a week, and only when there is something in it
Publishing on release is the right trigger only if releases happen. They had not: main ran 184 commits ahead of the last one, so `:latest` described a build that neither the demo, nor production, nor anyone building from source was running. This is the part that makes the trigger true without anyone having to remember. Mondays at 09:00 UTC. A run with no commits since the last release does nothing at all -- an empty release moves `:latest` to an identical build, spends a version number and mails every watcher about nothing. The decision is written to the run summary either way, so a quiet week reads as a decision rather than as a workflow that failed silently. The awkward part is that a release created with GITHUB_TOKEN raises no `release` event: GitHub refuses to let a token trigger another workflow, to stop a workflow looping on its own output. A scheduled job that cut a release and left publish.yml to notice would tag the commit and never build an image, which is the kind of failure that looks like success. So publish.yml gains a `workflow_call` trigger and this calls it directly. The alternative was a personal access token kept as a secret; this needs no credential. Two smaller decisions. Drafts are excluded when looking for the last release, because an unpublished draft is not a release anybody has and counting from it would hide commits that never shipped. And if the tag a release names has gone, the count falls back to the whole history -- over-counting cuts a release that was due anyway, where under-counting skips one that was not.
This commit is contained in:
@@ -26,6 +26,23 @@ name: Publish image
|
|||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
# Callable, so release.yml can build the release it just cut. This is not a
|
||||||
|
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
|
||||||
|
# `release` event -- GitHub refuses to let a token trigger another workflow,
|
||||||
|
# to stop a workflow looping on its own output. A scheduled job that cut a
|
||||||
|
# release and expected this file to notice would silently never publish. The
|
||||||
|
# alternatives are a personal access token kept as a secret, or calling the
|
||||||
|
# workflow directly. This is the one that needs no credential.
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Tag, branch or SHA to build"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
tag_latest:
|
||||||
|
description: "Also move :latest to this build"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
||||||
# orphans can be neither rerun nor cancelled, and this workflow otherwise
|
# orphans can be neither rerun nor cancelled, and this workflow otherwise
|
||||||
# only fires on a release -- which is not something to cut twice because a
|
# only fires on a release -- which is not something to cut twice because a
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Cut a release once a week, but only if there is something in it.
|
||||||
|
#
|
||||||
|
# Releases had drifted 184 commits behind main, which made `:latest` describe
|
||||||
|
# a build nobody was running -- the demo, prod and anyone building from source
|
||||||
|
# were all ahead of it. Publishing on release is the right trigger only if
|
||||||
|
# releases actually happen, so this is the part that makes that true without
|
||||||
|
# anyone having to remember.
|
||||||
|
#
|
||||||
|
# It does nothing on a quiet week. A release with no commits in it is worse
|
||||||
|
# than no release: it moves `:latest` to an identical build, spends a version
|
||||||
|
# number, and mails everybody watching the repository about nothing.
|
||||||
|
name: Weekly release
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Mondays, 09:00 UTC. GitHub runs scheduled jobs on a best-effort basis and
|
||||||
|
# can delay a run by a good while when the queue is busy, so do not read
|
||||||
|
# the exact minute as a promise. Note also that GitHub disables scheduled
|
||||||
|
# workflows in a repository with no activity for 60 days -- not a concern
|
||||||
|
# while this one is being worked on weekly, but it is why a silent stop is
|
||||||
|
# worth checking for before assuming the file is broken.
|
||||||
|
- cron: "0 9 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: "Work out what would be released, then stop"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
# One at a time. Two overlapping runs would race to create the same tag, and
|
||||||
|
# the loser fails noisily for a reason that has nothing to do with the code.
|
||||||
|
concurrency:
|
||||||
|
group: weekly-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
should_release: ${{ steps.decide.outputs.should_release }}
|
||||||
|
tag: ${{ steps.decide.outputs.tag }}
|
||||||
|
title: ${{ steps.decide.outputs.title }}
|
||||||
|
sha: ${{ steps.decide.outputs.sha }}
|
||||||
|
previous: ${{ steps.decide.outputs.previous }}
|
||||||
|
count: ${{ steps.decide.outputs.count }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- id: decide
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# The newest published release, or empty on a repository that has
|
||||||
|
# never had one -- in which case everything counts as new. Drafts are
|
||||||
|
# excluded: an unpublished draft is not a release anybody has, so
|
||||||
|
# counting from it would hide commits that have never shipped.
|
||||||
|
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
|
||||||
|
# A tag named by a release is normally present after a full checkout,
|
||||||
|
# but a release can outlive its tag. Falling back to the whole
|
||||||
|
# history is the safe direction to be wrong in: it over-counts, which
|
||||||
|
# cuts a release that was due anyway, where under-counting would skip
|
||||||
|
# one that was.
|
||||||
|
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
|
||||||
|
count="$(git rev-list --count "${previous}..HEAD")"
|
||||||
|
else
|
||||||
|
count="$(git rev-list --count HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
version="$(node scripts/version.mjs)"
|
||||||
|
# A Docker tag may not contain '+', and neither should the git tag,
|
||||||
|
# so the two always agree about what to call a build.
|
||||||
|
tag="v${version/+/-}"
|
||||||
|
title="v${version%%+*}"
|
||||||
|
sha="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
should_release=true
|
||||||
|
reason=""
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
should_release=false
|
||||||
|
reason="no commits since ${previous}"
|
||||||
|
elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||||
|
# Same commit, different week: the version is derived from the
|
||||||
|
# commit, so nothing new means the tag already exists.
|
||||||
|
should_release=false
|
||||||
|
reason="tag ${tag} already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "should_release=$should_release"
|
||||||
|
echo "tag=$tag"
|
||||||
|
echo "title=$title"
|
||||||
|
echo "sha=$sha"
|
||||||
|
echo "previous=$previous"
|
||||||
|
echo "count=$count"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Written to the run summary so a skipped week reads as a decision
|
||||||
|
# rather than as a workflow that quietly did nothing.
|
||||||
|
{
|
||||||
|
echo "### Weekly release"
|
||||||
|
echo
|
||||||
|
if [ "$should_release" = "true" ]; then
|
||||||
|
echo "Releasing **${tag}** — ${count} commit(s) since ${previous:-the beginning}."
|
||||||
|
else
|
||||||
|
echo "Nothing to release: ${reason}."
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
cut:
|
||||||
|
needs: check
|
||||||
|
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
- env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
args=(--target "${{ needs.check.outputs.sha }}"
|
||||||
|
--title "${{ needs.check.outputs.title }}"
|
||||||
|
--generate-notes)
|
||||||
|
# Bound the notes to what is actually new. Without a start tag the
|
||||||
|
# generator reaches back to whatever it decides is previous, which on
|
||||||
|
# a repository with older tag shapes is not always the last release.
|
||||||
|
if [ -n "${{ needs.check.outputs.previous }}" ]; then
|
||||||
|
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
|
||||||
|
fi
|
||||||
|
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
|
||||||
|
|
||||||
|
# Called rather than left to the `release` trigger on purpose: see the note
|
||||||
|
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
|
||||||
|
# event, so without this the tag would exist and no image would follow it.
|
||||||
|
publish:
|
||||||
|
needs: [check, cut]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
uses: ./.github/workflows/publish.yml
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.check.outputs.sha }}
|
||||||
|
tag_latest: true
|
||||||
Reference in New Issue
Block a user