feat: tag and publish a Gitea release on every push to master
Release / release (push) Successful in 6s

Replicate the dsh-facet release stack: a Gitea Actions workflow
(.gitea/workflows/release.yml) + .gitea/scripts/release.sh read the commit
subjects since the last tag, bump package.json, prepend CHANGELOG.md, tag
vX.Y.Z and publish the release via API. Agreed install ref is
#semver:^0.1.0, and tags are what 'dsh plugin --profile web update' moves
to, so the workflow is what makes updates work. Add the release.test.sh
suite (43 checks), npm scripts (test:release / release:plan /
release:notes), AGENTS.md with the commit conventions, CHANGELOG.md seed,
and the README *Releases* rules.
This commit is contained in:
Plugin Dev committed 2026-10-01 09:00:18 +01:00
1 parent 147227bb3c
commit 604eb44127
7 files changed
+760 -1

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
#
# Release plumbing for a dsh plugin repository.
#
# Kept as a script rather than inline YAML so it can be run and tested without a
# runner. The workflow is a thin wrapper around a handful of subcommands:
#
# last-tag the most recent v* tag, or empty
# bump <range> patch | minor | major, from the commit subjects
# next-version <bump> the version to release
# notes <range> markdown release notes for the range
#
# The bump is read from the commit subjects in the range being released:
#
# a subject containing the word `feat` minor (0.1.0 -> 0.2.0)
# a `!:` subject, or BREAKING major (0.1.0 -> 1.0.0)
# anything else patch (0.1.0 -> 0.1.1)
#
# and two explicit markers override that when the subject alone is ambiguous:
#
# [major] release as a major version
# [minor] release as a minor version
# [skip release] do not release this push at all
#
# Writing the intent in the commit subject means the decision travels with the
# change rather than living in a file somebody has to remember to update.
set -euo pipefail
MARKER_MAJOR='[major]'
MARKER_MINOR='[minor]'
MARKER_SKIP='[skip release]'
# The most recent release tag, or nothing when the repository has never released.
last_tag() {
git tag --list 'v[0-9]*' --sort=-v:refname | head -1
}
# The version currently declared in package.json (or a crate/module equivalent).
current_version() {
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1
}
# Commits in a range, newest last, one subject per line.
subjects_in() {
local range="$1"
if [ -z "$range" ]; then
git log --no-merges --format='%s'
else
git log --no-merges --format='%s' "$range"
fi
}
# Whether any commit in the range asks for no release.
wants_skip() {
subjects_in "${1:-}" | grep -qF "$MARKER_SKIP"
}
# The commit range this release covers: `<last tag>..HEAD`, or empty when the
# repository has never released. Computed here rather than in the workflow so
# the runner, the npm script and a human all get the same answer.
release_range() {
local tag
tag="$(last_tag)"
if [ -n "$tag" ]; then
echo "${tag}..HEAD"
fi
}
# Whether this push should produce a release at all.
#
# The empty-range case is not hypothetical: the release commit this workflow
# pushes is itself a push to master, and without this guard the next run would
# find nothing to describe and publish a release saying so. `[skip release]` in
# the release commit is the second line of defence.
should_release() {
local range="$1"
if wants_skip "$range"; then
echo no
return
fi
if [ -z "$(subjects_in "$range")" ]; then
echo no
return
fi
echo yes
}
# Whether a subject claims a feature.
#
# `feat` as its own word, so `feat:`, `feat(x):` and `feat something` all count,
# while `Fix the feature flag leak` does not.
is_feature() {
printf '%s\n' "${1:-}" | grep -Eqi '(^|[^a-z0-9_])feat([^a-z0-9_]|$)'
}
# Whether a subject claims a breaking change.
#
# Only the subject is read, so this is the conventional `!:` form — a
# `BREAKING CHANGE:` trailer in a body is invisible here. Use `[major]` for the
# cases a subject cannot carry.
is_breaking() {
printf '%s\n' "${1:-}" | grep -Eqi '^[a-z]+(\([^)]*\))?!:|BREAKING[ -]?CHANGE'
}
# The bump implied by the commits in a range. Markers win over the subject.
bump_for() {
local subjects
subjects="$(subjects_in "${1:-}")"
if printf '%s\n' "$subjects" | grep -qF "$MARKER_MAJOR" || is_breaking "$subjects"; then
echo major
elif printf '%s\n' "$subjects" | grep -qF "$MARKER_MINOR" || is_feature "$subjects"; then
echo minor
else
echo patch
fi
}
# semver arithmetic, without pulling in a node dependency.
apply_bump() {
local version="$1" bump="$2"
local major minor patch
IFS=. read -r major minor patch <<< "${version%%-*}"
major="${major:-0}"; minor="${minor:-0}"; patch="${patch:-0}"
case "$bump" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
*) echo "unknown bump: $bump" >&2; return 1 ;;
esac
echo "${major}.${minor}.${patch}"
}
# The version this push should release.
#
# With no tag yet, the declared version *is* the release: tagging 0.1.0 rather
# than inventing 0.1.1 means the first release matches what the code says, and
# every later one moves forward from it.
next_version() {
local bump="$1" tag current
tag="$(last_tag)"
current="$(current_version)"
if [ -z "$tag" ]; then
echo "${current:-0.1.0}"
return
fi
apply_bump "${tag#v}" "$bump"
}
# Strip a conventional-commit prefix, keeping the sentence intact.
strip_prefix() {
printf '%s' "$1" | sed -E 's/^[a-z]+(\([^)]*\))?!?:[[:space:]]*//'
}
# The type of a conventional commit, or empty.
commit_type() {
printf '%s' "$1" | sed -nE 's/^([a-z]+)(\([^)]*\))?!?:.*/\1/p'
}
# Markdown release notes for a range.
#
# Grouped only when the range actually uses conventional prefixes — otherwise a
# flat list. Inventing headings for commits that do not carry types would put a
# label on a change that never claimed one.
notes() {
local range="$1" total typed subject type
local subjects
subjects="$(subjects_in "$range" | grep -vF "$MARKER_SKIP" || true)"
if [ -z "$subjects" ]; then
echo "_No changes._"
return
fi
total="$(printf '%s\n' "$subjects" | grep -c . || true)"
typed="$(printf '%s\n' "$subjects" | while IFS= read -r subject; do
[ -n "$(commit_type "$subject")" ] && echo x
done | grep -c . || true)"
# Half or more carrying a type is enough to be a convention rather than a
# coincidence, and a couple of strays can still be filed under "Other".
if [ "$total" -gt 0 ] && [ "$typed" -ge $(( (total + 1) / 2 )) ]; then
local breakings fixes features docs others
breakings=''; fixes=''; features=''; docs=''; others=''
while IFS= read -r subject; do
if [ -z "$subject" ]; then
continue
fi
# Checked before the type, so `feat!:` lands under Breaking rather than
# under Added.
if is_breaking "$subject"; then
breakings="${breakings}- $(strip_prefix "$subject")"$'\n'
continue
fi
type="$(commit_type "$subject")"
subject="$(strip_prefix "$subject")"
case "$type" in
feat) features="${features}- ${subject}"$'\n' ;;
fix|perf|revert) fixes="${fixes}- ${subject}"$'\n' ;;
docs) docs="${docs}- ${subject}"$'\n' ;;
*) others="${others}- ${subject}"$'\n' ;;
esac
done <<< "$subjects"
# Written as explicit `if`s rather than `[ -n "$x" ] && printf`: an empty
# section makes that test fail, the `&&` list returns non-zero, and under
# `set -e` the whole release aborts. A repository with no breaking changes
# is the normal case, not an error.
if [ -n "$breakings" ]; then printf '### Breaking\n\n%s\n' "$breakings"; fi
if [ -n "$features" ]; then printf '### Added\n\n%s\n' "$features"; fi
if [ -n "$fixes" ]; then printf '### Fixed\n\n%s\n' "$fixes"; fi
if [ -n "$docs" ]; then printf '### Documented\n\n%s\n' "$docs"; fi
if [ -n "$others" ]; then printf '### Other\n\n%s\n' "$others"; fi
return
fi
printf '%s\n' "$subjects" | while IFS= read -r subject; do
if [ -n "$subject" ]; then
printf -- '- %s\n' "$(strip_prefix "$subject")"
fi
done
}
# Write a version into package.json, preserving the rest of the file byte for byte.
set_version() {
local version="$1" tmp
tmp="$(mktemp)"
sed -E "s/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")/\1${version}\2/" package.json > "$tmp"
mv "$tmp" package.json
}
# Prepend one release section to CHANGELOG.md.
#
# The notes file is passed by path rather than by argument because release notes
# are multi-line markdown, and a shell argument would mangle them.
changelog_prepend() {
local version="$1" date="$2" notes_file="$3" tmp
tmp="$(mktemp)"
{
# The title and preamble stay at the top; only the sections move.
if [ -f CHANGELOG.md ]; then
# Buffer the preamble and drop its trailing blank lines, so repeated
# releases do not accumulate whitespace where the sections meet.
awk '/^## / { exit } { print }' CHANGELOG.md \
| awk 'BEGIN { n = 0 } { line[n++] = $0 }
END { while (n > 0 && line[n-1] == "") n--
for (i = 0; i < n; i++) printf "%s%s", (i ? "\n" : ""), line[i] }'
else
printf '# Changelog\n\nEvery release, newest first, generated from the commits in each release.'
fi
printf '\n\n## v%s — %s\n\n' "$version" "$date"
cat "$notes_file"
printf '\n'
if [ -f CHANGELOG.md ]; then
awk 'BEGIN { seen = 0 } /^## / { seen = 1 } seen == 1 { print }' CHANGELOG.md
fi
} > "$tmp"
mv "$tmp" CHANGELOG.md
}
case "${1:-}" in
last-tag) shift; last_tag ;;
wants-skip) shift; wants_skip "${1:-}" && echo yes || echo no ;;
range) release_range ;;
should-release) shift; should_release "${1:-}" ;;
bump) shift; bump_for "${1:-}" ;;
is-feature) shift; is_feature "${1:-}" && echo yes || echo no ;;
is-breaking) shift; is_breaking "${1:-}" && echo yes || echo no ;;
next-version) shift; next_version "${1:-patch}" ;;
apply-bump) shift; apply_bump "$1" "$2" ;;
notes) shift; notes "${1:-}" ;;
current-version) current_version ;;
set-version) shift; set_version "$1" ;;
changelog-prepend) shift; changelog_prepend "$1" "$2" "$3" ;;
*)
cat >&2 <<'USAGE'
usage: release.sh <command>
last-tag most recent v* tag, or empty
wants-skip <range> yes when the range asks for no release
range the commit range this release covers
should-release <range> yes when this push should publish a release
bump <range> patch | minor | major
is-feature <subject> yes when a subject claims a feature
is-breaking <subject> yes when a subject claims a breaking change
next-version <bump> the version to release
apply-bump <version> <bump> semver arithmetic
notes <range> markdown release notes
current-version version declared in package.json
set-version <version> update package.json in place
changelog-prepend <version> <date> <notes-file>
USAGE
exit 2
;;
esac
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
#
# Tests for the release script, in throwaway git repositories.
#
# These paths only occur at moments you cannot stage by hand — a repository with
# no tags, a range that asks for no release, a version whose patch number is
# already two digits — and getting them wrong publishes a wrong version or no
# release at all. Run with: bash .gitea/scripts/release.test.sh
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RELEASE="$HERE/release.sh"
PASS=0
FAIL=0
# A fresh repository with one commit, optionally tagged.
new_repo() {
local dir
dir="$(mktemp -d)"
cd "$dir"
git init -q
git config user.email test@example.com
git config user.name Test
printf '{\n "name": "thing",\n "version": "%s"\n}\n' "${1:-0.1.0}" > package.json
git add -A
git commit -qm "Scaffold the thing"
echo "$dir"
}
commit() {
git commit -q --allow-empty -m "$1"
}
tag() {
git tag "$1"
}
check() {
local label="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
PASS=$((PASS + 1))
printf ' ok %s\n' "$label"
else
FAIL=$((FAIL + 1))
printf ' FAIL %s\n expected: %s\n actual: %s\n' "$label" "$expected" "$actual"
fi
}
check_contains() {
local label="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
PASS=$((PASS + 1))
printf ' ok %s\n' "$label"
else
FAIL=$((FAIL + 1))
printf ' FAIL %s\n missing: %s\n in: %s\n' "$label" "$needle" "$haystack"
fi
}
check_absent() {
local label="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
FAIL=$((FAIL + 1))
printf ' FAIL %s\n should not contain: %s\n' "$label" "$needle"
else
PASS=$((PASS + 1))
printf ' ok %s\n' "$label"
fi
}
echo "semver arithmetic"
check "patch" "0.1.1" "$(bash "$RELEASE" apply-bump 0.1.0 patch)"
check "minor" "0.2.0" "$(bash "$RELEASE" apply-bump 0.1.5 minor)"
check "major" "1.0.0" "$(bash "$RELEASE" apply-bump 0.9.9 major)"
check "patch to 10" "0.1.10" "$(bash "$RELEASE" apply-bump 0.1.9 patch)"
check "minor to 10" "0.10.0" "$(bash "$RELEASE" apply-bump 0.9.3 minor)"
check "prerelease" "0.1.1" "$(bash "$RELEASE" apply-bump 0.1.0-rc.1 patch)"
echo
echo "first release with no tags"
new_repo 0.2.0 >/dev/null
check "version is the declared one" "0.2.0" "$(bash "$RELEASE" next-version patch)"
check "no tag yet" "" "$(bash "$RELEASE" last-tag)"
cd /;
echo
echo "subsequent releases move forward from the tag"
new_repo 0.1.0 >/dev/null
tag v0.1.3
check "patch" "0.1.4" "$(bash "$RELEASE" next-version patch)"
check "minor" "0.2.0" "$(bash "$RELEASE" next-version minor)"
check "major" "1.0.0" "$(bash "$RELEASE" next-version major)"
commit "Fix the thing"
check "bump defaults to patch" "patch" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
commit "Add a capability [minor]"
check "marker raises the bump" "minor" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
check "and the version follows" "0.2.0" "$(bash "$RELEASE" next-version "$(bash "$RELEASE" bump "v0.1.3..HEAD")")"
commit "Rewrite everything [major]"
check "major outranks minor" "major" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
cd /;
echo
echo "the subject decides the bump"
new_repo 0.1.0 >/dev/null
tag v0.1.0
check "prose is a patch" "patch" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
commit "feat: add the recall index"
check "feat is a minor" "minor" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
check "so 0.1 -> 0.2" "0.2.0" "$(bash "$RELEASE" next-version "$(bash "$RELEASE" bump "v0.1.0..HEAD")")"
cd /;
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "feat(scope): add the scope filter"
check "a scoped feat counts" "minor" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
cd /;
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "Fix the feature flag leak"
check "feat inside a word is not a feature" "no" "$(bash "$RELEASE" is-feature "Fix the feature flag leak")"
check "so it stays a patch" "patch" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
cd /;
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "feat!: move the index to sqlite"
check "a bang is breaking" "yes" "$(bash "$RELEASE" is-breaking "feat!: move the index to sqlite")"
check "so it is a major" "major" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
check "and 0.1 -> 1.0" "1.0.0" "$(bash "$RELEASE" next-version "$(bash "$RELEASE" bump "v0.1.0..HEAD")")"
NOTES="$(bash "$RELEASE" notes "v0.1.0..HEAD")"
check_contains "breaking gets a section" "### Breaking" "$NOTES"
check_contains "and is listed there" "- move the index to sqlite" "$NOTES"
check_absent "not under Added" "### Added" "$NOTES"
cd /;
echo
echo "skip release"
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "Update the README [skip release]"
check "skip is detected" "yes" "$(bash "$RELEASE" wants-skip "v0.1.0..HEAD")"
check_absent "skipped work is not in the notes" "Update the README" "$(bash "$RELEASE" notes "v0.1.0..HEAD")"
commit "Fix a real bug"
check "a later real change does not un-skip" "yes" "$(bash "$RELEASE" wants-skip "v0.1.0..HEAD")"
check "so nothing is released" "no" "$(bash "$RELEASE" should-release "v0.1.0..HEAD")"
cd /;
echo
echo "notes only cover the range"
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "Fix the leaked handle"
commit "Add the status card"
NOTES="$(bash "$RELEASE" notes "v0.1.0..HEAD")"
check_contains "includes the new work" "Add the status card" "$NOTES"
check_contains "includes the fix" "Fix the leaked handle" "$NOTES"
check_absent "excludes released work" "Scaffold the thing" "$NOTES"
check "a range with real work releases" "yes" "$(bash "$RELEASE" should-release "v0.1.0..HEAD")"
cd /;
echo
echo "conventional commits are grouped, plain ones are not"
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "feat: add the release workflow"
commit "fix: stop the runner tagging twice"
commit "docs: explain the markers"
NOTES="$(bash "$RELEASE" notes "v0.1.0..HEAD")"
check_contains "grouped heading" "### Added" "$NOTES"
check_contains "grouped fix" "### Fixed" "$NOTES"
check_contains "prefix stripped" "- add the release workflow" "$NOTES"
check_absent "no raw prefix" "feat: add" "$NOTES"
cd /;
echo
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "Background only the warm-up, and use the framework's own parameter name"
commit "Make the settings card behave like the other cards"
NOTES="$(bash "$RELEASE" notes "v0.1.0..HEAD")"
check_absent "prose commits get no invented headings" "### Added" "$NOTES"
check_contains "and stay as bullets" "- Make the settings card behave" "$NOTES"
cd /;
echo
echo "an empty range says so rather than printing nothing"
new_repo 0.1.0 >/dev/null
tag v0.1.0
check "empty range" "_No changes._" "$(bash "$RELEASE" notes "v0.1.0..HEAD")"
# The release commit is itself a push to master; without this the next run would
# publish a release describing nothing.
check "an empty range releases nothing" "no" "$(bash "$RELEASE" should-release "v0.1.0..HEAD")"
cd /;
echo
printf 'passed %d, failed %d\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]
+149
View File
@@ -0,0 +1,149 @@
# Publish a release every time master moves.
#
# What this is for, beyond tidiness: a dsh plugin installed as a bare
# `git+https://…/repo.git` is resolved to a commit and pinned in the profile's
# lockfile. With no tags, there is nothing for `dsh plugin update` to move to —
# which is why updating appeared to do nothing and a reinstall was the only way
# through. Tags are what make an update possible; this workflow is what creates
# them.
#
# The work lives in `.gitea/scripts/release.sh` so it can be run and tested
# without a runner (`bash .gitea/scripts/release.test.sh`, or `npm run
# test:release`).
#
# The bump comes from the commit subjects in the range being released:
# `feat` is a minor, `!:` (or an explicit `[major]`) is a major, and everything
# else — `fix:`, `docs:`, prose — is a patch. `[skip release]` in a subject
# suppresses the release for that push.
name: Release
on:
push:
branches: [master]
# Manual runs, for the first release (no tags yet) or to publish a release
# whose push failed after the tag was created.
workflow_dispatch:
# One release at a time. Two pushes in quick succession would otherwise race to
# tag the same commit and one of them would lose.
concurrency:
group: release-master
cancel-in-progress: false
permissions:
# The job token is what pushes the version commit, the tag, and the release.
contents: write
jobs:
release:
runs-on: ubuntu-latest
env:
BRANCH: master
steps:
- uses: actions/checkout@v4
with:
# Tags are the whole point, so the clone must carry them.
fetch-depth: 0
ref: master
# Gitea mints a token for every job and exposes it as
# secrets.GITEA_TOKEN; `permissions: contents: write` above is what
# lets it push. No hand-made secret is needed.
token: ${{ secrets.GITEA_TOKEN }}
- name: Decide whether this push releases
id: decide
run: |
set -euo pipefail
tag="$(bash .gitea/scripts/release.sh last-tag)"
range="$(bash .gitea/scripts/release.sh range)"
echo "range=${range}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
verdict="$(bash .gitea/scripts/release.sh should-release "$range")"
echo "release=${verdict}" >> "$GITHUB_OUTPUT"
echo "last tag: ${tag:-none} range: ${range:-everything} release: ${verdict}"
- name: Work out the version and the notes
if: steps.decide.outputs.release == 'yes'
id: version
run: |
set -euo pipefail
bump="$(bash .gitea/scripts/release.sh bump "${{ steps.decide.outputs.range }}")"
version="$(bash .gitea/scripts/release.sh next-version "$bump")"
bash .gitea/scripts/release.sh notes "${{ steps.decide.outputs.range }}" > /tmp/notes.md
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "bump=${bump}" >> "$GITHUB_OUTPUT"
echo "releasing v${version} (${bump})"
cat /tmp/notes.md
- name: Bump, changelog, commit and tag
if: steps.decide.outputs.release == 'yes'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
git config user.name "gitea-actions"
git config user.email "actions@notexpectedyet.com"
# Idempotency: a run that pushed the tag and then failed at the
# release call must not fail here on the re-run.
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "tag v${VERSION} already exists — publishing the release for it"
exit 0
fi
bash .gitea/scripts/release.sh set-version "$VERSION"
bash .gitea/scripts/release.sh changelog-prepend "$VERSION" "$(date -u +%Y-%m-%d)" /tmp/notes.md
git add package.json CHANGELOG.md
# `[skip release]` is the guard that has to hold, because the script
# is what decides. `[skip actions]` is the cheap extra: Gitea reads it
# and can skip queueing a run for this commit at all.
git commit -m "Release v${VERSION} [skip release] [skip actions]"
git tag "v${VERSION}"
git push origin "HEAD:${BRANCH}"
git push origin "v${VERSION}"
- name: Publish the release
if: steps.decide.outputs.release == 'yes'
env:
VERSION: ${{ steps.version.outputs.version }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# The body is built by an encoder rather than by string interpolation:
# release notes are multi-line markdown and quoting them into JSON by
# hand is a reliable way to publish a malformed release.
python3 - <<'PY' > /tmp/release.json
import json, os
with open('/tmp/notes.md', encoding='utf-8') as handle:
body = handle.read()
version = os.environ['VERSION']
print(json.dumps({
'tag_name': 'v' + version,
'name': 'v' + version,
'body': body,
'draft': False,
'prerelease': False,
}))
PY
status="$(curl -sS -o /tmp/release-response.json -w '%{http_code}' \
-X POST "${SERVER}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/release.json)"
# 409 is "a release for this tag already exists": the job has nothing
# left to do rather than something to report.
if [ "$status" = "409" ]; then
echo "v${VERSION} is already released"
exit 0
fi
if [ "$status" != "201" ] && [ "$status" != "200" ]; then
echo "release creation failed (HTTP ${status})" >&2
cat /tmp/release-response.json >&2
exit 1
fi
echo "published v${VERSION}"