main is protected as of today -- no force-push, no deletion, and a pull
request with a green build to merge -- and GITHUB_TOKEN is not a bypass
actor. `git push origin HEAD:main` in the cut job would have been refused
from Monday, on a scheduled run nobody watches.
GitHub would not take the obvious fix. Adding the Actions integration as a
bypass actor is rejected ("must be part of the ruleset source or owner
organization") because the organization has no app installations. The
other two routes -- an organization-level ruleset, a deploy key with write
access -- both amount to handing the release a credential that outranks
the rule, which is a worse thing to own than a slower Monday.
So the bump lands the way every other change does. It commits to
release/v<version>, opens a pull request, waits for the build the ruleset
requires, merges, and tags what came out. The waiting is not merely the
rule being satisfied: a release cut from a tree that does not compile is
the failure this whole arrangement exists to prevent, and until now
nothing checked.
Three details that would each have produced a wrong tag. The sha comes
from GitHub's merge commit, not the tip that was pushed, because a rebase
merge rewrites it. The pull request is tracked by number, not by branch,
because the branch is deleted on merge and a deleted branch no longer
resolves to its pull request. And a failed or slow build leaves the pull
request open and cuts nothing, rather than tagging whatever main happened
to hold.
Quiet weeks are unaffected: the tag still names the bump commit, so
`previous..HEAD` is still zero when nothing else has landed.
The cost is a Monday run that now takes as long as a full build -- about
25 minutes at the moment, most of it saving the cache.
247 lines
10 KiB
YAML
247 lines
10 KiB
YAML
# Cut a release once a week, but only if there is something in it.
|
|
#
|
|
# 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.
|
|
#
|
|
# INBUXA's version is a string in crates/types/src/branding.rs, deliberately
|
|
# not in Cargo.toml so that upstream's version bumps merge without conflicts.
|
|
# So this writes it: the bump is committed to main, and the tag names that
|
|
# commit. The tree a tag points at therefore reports the version the tag
|
|
# claims, which a tag placed beside an unbumped macro cannot promise.
|
|
name: Weekly release
|
|
|
|
on:
|
|
schedule:
|
|
# Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail
|
|
# release ahead of the server they talk to. Staggered rather than
|
|
# simultaneous so three releases do not compete for runners, and so a bad
|
|
# Monday names one repository instead of three. GitHub runs scheduled jobs
|
|
# best-effort and can delay a run considerably, so the exact minute is not
|
|
# a promise; the odd minute keeps it off the crowded top of the hour.
|
|
#
|
|
# Note also that GitHub disables scheduled workflows in a repository with
|
|
# no activity for 60 days, which is worth checking for before assuming
|
|
# this file is broken.
|
|
- cron: "7 10 * * 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 write the same version and
|
|
# 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 }}
|
|
version: ${{ steps.decide.outputs.version }}
|
|
tag: ${{ steps.decide.outputs.tag }}
|
|
previous: ${{ steps.decide.outputs.previous }}
|
|
count: ${{ steps.decide.outputs.count }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: main
|
|
fetch-depth: 0
|
|
- 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
|
|
|
|
# INBUXA's version is the date: YYYY.M.D, unpadded, as branding.rs
|
|
# documents. A second release on one day takes a `.N` suffix,
|
|
# counting from 2, which is why this asks the tags rather than
|
|
# assuming today is free.
|
|
today="$(date -u +%Y.%-m.%-d)"
|
|
version="$today"
|
|
n=2
|
|
while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
|
|
version="${today}.${n}"
|
|
n=$((n + 1))
|
|
done
|
|
|
|
should_release=true
|
|
reason=""
|
|
if [ "$count" -eq 0 ]; then
|
|
should_release=false
|
|
reason="no commits since ${previous}"
|
|
fi
|
|
|
|
{
|
|
echo "should_release=$should_release"
|
|
echo "version=$version"
|
|
echo "tag=v${version}"
|
|
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 **v${version}** — ${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
|
|
pull-requests: write
|
|
outputs:
|
|
sha: ${{ steps.land.outputs.sha }}
|
|
steps:
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: main
|
|
fetch-depth: 0
|
|
- id: bump
|
|
env:
|
|
VERSION: ${{ needs.check.outputs.version }}
|
|
BRANCH: release/v${{ needs.check.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Scoped to the macro body rather than replacing the first quoted
|
|
# string in the file, and asserted to have matched exactly once.
|
|
# branding.rs holds other string literals, and a bump that silently
|
|
# edited one of those -- or none -- would ship a build whose version
|
|
# disagrees with its tag.
|
|
python3 - <<'PY'
|
|
import os, re
|
|
path = "crates/types/src/branding.rs"
|
|
src = open(path, encoding="utf-8").read()
|
|
pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")')
|
|
out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1)
|
|
assert n == 1, f"brand_version! not found in {path}"
|
|
open(path, "w", encoding="utf-8").write(out)
|
|
PY
|
|
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
git add crates/types/src/branding.rs
|
|
git commit -m "Version ${VERSION}"
|
|
git push origin "HEAD:refs/heads/${BRANCH}"
|
|
|
|
# main is protected: it takes a pull request with a green build, and
|
|
# GITHUB_TOKEN is not among the bypass actors. So the bump lands the way
|
|
# every other change does. The alternative was to hand the release a
|
|
# credential that outranks the rule, which is a worse thing to own than
|
|
# a slower Monday.
|
|
- id: land
|
|
env:
|
|
VERSION: ${{ needs.check.outputs.version }}
|
|
BRANCH: release/v${{ needs.check.outputs.version }}
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
url="$(gh pr create --base main --head "${BRANCH}" \
|
|
--title "Version ${VERSION}" \
|
|
--body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")"
|
|
# The number, not the branch: the branch is deleted on merge, and a
|
|
# deleted branch no longer resolves to its pull request.
|
|
pr="${url##*/}"
|
|
echo "Opened #${pr}"
|
|
|
|
# The build is what the rule actually requires, and it is also the
|
|
# thing worth waiting for: a release cut from a tree that does not
|
|
# compile is the failure this whole arrangement exists to prevent.
|
|
# A full build of this tree is long, so the deadline is generous.
|
|
deadline=$(( SECONDS + 3600 ))
|
|
while :; do
|
|
state="$(gh pr view "${pr}" --json statusCheckRollup \
|
|
--jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')"
|
|
case "${state}" in
|
|
*FAILURE*|*CANCELLED*|*TIMED_OUT*)
|
|
echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open."
|
|
exit 1 ;;
|
|
*SUCCESS*) break ;;
|
|
esac
|
|
if [ "${SECONDS}" -ge "${deadline}" ]; then
|
|
echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open."
|
|
exit 1
|
|
fi
|
|
sleep 30
|
|
done
|
|
|
|
gh pr merge "${pr}" --rebase --delete-branch
|
|
|
|
# A rebase merge rewrites the commit, so the sha to tag is the one
|
|
# GitHub recorded for the merge, not the tip that was pushed. It can
|
|
# take a moment to appear.
|
|
sha=""
|
|
for _ in $(seq 1 30); do
|
|
sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')"
|
|
[ -n "${sha}" ] && break
|
|
sleep 5
|
|
done
|
|
if [ -z "${sha}" ]; then
|
|
echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag."
|
|
exit 1
|
|
fi
|
|
|
|
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
|
|
- env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
args=(--target "${{ steps.land.outputs.sha }}"
|
|
--title "INBUXA ${{ needs.check.outputs.version }}"
|
|
--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 carrying upstream's 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.cut.outputs.sha }}
|
|
tag_latest: true
|