Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 26 additions & 36 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
name: Release

# A release published on the Releases page builds its tag and attaches the packages to it.
on:
push:
tags: ['v*']
release:
types: [published]
workflow_dispatch:

permissions:
contents: write

jobs:
# The version is the tag (MinVer; see Directory.Build.props). A tag is refused before anything is built
# unless CHANGELOG.md has a dated section for it. See docs/RELEASING.md.
# The version is the tag (MinVer; see Directory.Build.props). See docs/RELEASING.md.
version:
runs-on: ubuntu-latest
outputs:
Expand All @@ -31,18 +31,12 @@ jobs:
# A tag builds exactly its own version; a manual run builds what MinVer makes of the commit, e.g.
# 0.2.1-alpha.0.5. The installer's file version has to be four numbers, so a prerelease suffix is
# dropped there.
# Releases are immutable once published, so a release that already exists for this tag (one made in the
# GitHub UI, say) can never receive the packages. Refuse it here rather than after the build.
- name: Version
id: version
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
python3 tools/changelog.py release-check "$GITHUB_REF_NAME"
if [ "$(gh release view "$GITHUB_REF_NAME" --json isDraft -q .isDraft 2>/dev/null)" = "false" ]; then
echo "::error::$GITHUB_REF_NAME is already published and immutable, so nothing can be attached to it. Release the next version; push only the tag and let this workflow create the release."
if ! [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::the tag $GITHUB_REF_NAME is not v<semantic version>, e.g. v1.2.3 or v1.3.0-beta.1"
exit 1
fi
version="${GITHUB_REF_NAME#v}"
Expand Down Expand Up @@ -129,45 +123,41 @@ jobs:
name: sharpmuterm-${{ matrix.rid }}
path: dist/*

# Serialize release creation in a single downstream job so the two matrix jobs don't race
# softprops/action-gh-release on the same tag.
# One job attaches every package, after both matrix jobs have built theirs.
release:
needs: [version, publish]
# A manual run, even against a tag, keeps its packages as workflow artifacts.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
if: github.event_name == 'release'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Release notes from CHANGELOG.md
run: python3 tools/changelog.py notes ${{ needs.version.outputs.version }} > release-notes.md

- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true

# Assets can only be attached before a release is published, so the workflow makes a draft and fills it.
- name: Create draft release and upload assets
uses: softprops/action-gh-release@v2
with:
name: SharpMUTerm ${{ needs.version.outputs.version }}
body_path: release-notes.md
prerelease: ${{ contains(needs.version.outputs.version, '-') }}
draft: true
files: artifacts/*
fail_on_unmatched_files: true

# Publishing is a person's step, on the Releases page: once published, the release can no longer change.
- name: Check the draft carries all four packages
# --clobber replaces a package of the same name, so re-running the workflow is safe.
- name: Attach the packages
run: |
gh release upload "$TAG" artifacts/* --clobber
count=$(gh release view "$TAG" --json assets -q '.assets | length')
test "$count" -ge 4 || { echo "::error::the release has $count packages, expected 4"; exit 1; }

# Notes written on the Releases page are kept. Empty notes get the version's changelog section, else
# [Unreleased]; with neither, they stay empty.
- name: Release notes from CHANGELOG.md
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
BODY: ${{ github.event.release.body }}
run: |
count=$(gh release view "$GITHUB_REF_NAME" --json assets -q '.assets | length')
test "$count" -eq 4 || { echo "::error::the draft has $count packages, expected 4"; exit 1; }
echo "Draft ready: $(gh release view "$GITHUB_REF_NAME" --json url -q .url). Review it and press Publish."
if [ -n "$BODY" ]; then exit 0; fi
python3 tools/changelog.py notes ${{ needs.version.outputs.version }} > release-notes.md
if [ -s release-notes.md ]; then gh release edit "$TAG" --notes-file release-notes.md; fi
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
Every change a user could notice is recorded here, in the release that ships it. The format is
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). `tools/changelog.py check` enforces the shape,
and a release fails without its section. The procedure is in [`docs/RELEASING.md`](docs/RELEASING.md).
and a release's notes are its section. The procedure is in [`docs/RELEASING.md`](docs/RELEASING.md).

## [Unreleased]

## [0.1.2] - 2026-10-25
## [0.1.4] - 2026-10-25

### Added

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ not just SharpMUSH ones.
The goal is [BeipMU](https://beipdev.github.io/BeipMU/)-class feature parity in a terminal-native
client: rich truecolor text, inline graphics, powerful automation, and full MU\* protocol support.

> **Status:** first release, **0.1.2**. It is a usable text client with automation engines, inline
> **Status:** first release, **0.1.4**. It is a usable text client with automation engines, inline
> graphics, Lua scripting, theming and a desktop window, all covered by unit tests. Each release's changes are
> in [`CHANGELOG.md`](CHANGELOG.md). The architecture and roadmap are in [`docs/PLAN.md`](docs/PLAN.md).

Expand Down
39 changes: 14 additions & 25 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ that can disagree with the tag. Before the first tag, builds are `0.1.0-alpha.0.

What the repository does hold is **`CHANGELOG.md`** — [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Newest first; an `## [Unreleased]` section on top; each release `## [X.Y.Z] - YYYY-MM-DD` with entries under
`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed` or `Security`. A tag without its section is not released.
`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed` or `Security`.

## Rules, and what enforces them

| Rule | Enforced by |
|---|---|
| The changelog has `[Unreleased]` first, release headings are `[X.Y.Z] - YYYY-MM-DD`, newest first by version and by date, no empty releases, only the six subsection names | `tools/changelog.py check` in the **Changelog** workflow, on every PR and push to `main` |
| A PR that changes `src/`, `packaging/` or `Directory.*.props` adds a line under `[Unreleased]` | **Changelog** workflow; label the PR `skip-changelog` when no user could notice the change |
| A release tag is `v<semantic version>` and the changelog has a dated, non-empty section for it | `tools/changelog.py release-check` in **Release**, before anything is built |
| The GitHub release's notes are that section, verbatim | **Release** (`changelog.py notes`) |
| A release tag is `v<semantic version>` | **Release**, before anything is built |
| A release published with empty notes gets the tag's changelog section, verbatim; without one, `[Unreleased]` | **Release** (`changelog.py notes`) |

## Versions

Expand All @@ -26,28 +26,17 @@ minor bump and is named in the changelog. A prerelease (`0.2.0-beta.1`) is publi

## Cutting a release

1. Open a PR that renames `## [Unreleased]` to `## [X.Y.Z] - <today>` and puts a fresh, empty
`## [Unreleased]` above it. Merge it once CI is green.
2. Tag the merge commit and push the tag:
```bash
git tag vX.Y.Z <merge-commit>
git push origin vX.Y.Z
```
3. **Release** builds the terminal and the window for Linux and Windows at exactly `X.Y.Z`. It creates a **draft**
GitHub release, `SharpMUTerm X.Y.Z`, with the changelog section as its notes, and attaches the `.tar.gz`, the
AppImage, the `.zip` and the installer. A tag with a prerelease suffix (`v0.2.0-beta.1`) is drafted as a
prerelease.
4. Open the draft on the Releases page, check it, and press **Publish release**. The release is immutable from
that point on.

A tag is never moved or reused, because the repository has immutable releases on. If a tag fails the check, it
builds nothing: fix the changelog, rename its section to the next patch version, and tag that instead.

**Don't create the release yourself; publish the draft the workflow makes.** A published release is immutable, so
its packages can't be attached afterwards, and GitHub only attaches files to a draft. A tag that already has a
published release is refused before the build. Neither `v0.1.0` (no
changelog section) nor `v0.1.1` (released from the UI with no packages) shipped packages, so the first release
with packages is 0.1.2.
1. Optionally, open a PR that renames `## [Unreleased]` to `## [X.Y.Z] - <today>` and puts a fresh, empty
`## [Unreleased]` above it.
2. On the Releases page, **Draft a new release**: create the tag `vX.Y.Z` on `main`, title it, and publish it.
Leave the notes empty to have the changelog filled in, or write your own. Tick *Set as a pre-release* for a
version like `v0.2.0-beta.1`.
3. Publishing runs **Release**. It builds the terminal and the window for Linux and Windows at exactly `X.Y.Z`
and attaches the `.tar.gz`, the AppImage, the `.zip` and the installer to the release. Re-running the workflow replaces them.

A tag is never moved or reused. If a release goes wrong, release the next patch version. `v0.1.0` to `v0.1.3`
shipped no packages, and the last three were immutable releases, which can never take files. The first release
with packages is 0.1.4.

Running **Release** by hand (workflow dispatch) builds whatever MinVer makes of that commit and keeps the
packages as workflow artifacts; it publishes nothing.
26 changes: 5 additions & 21 deletions tools/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@

changelog.py check the changelog's shape
changelog.py unreleased-grew <ref> [Unreleased] has an entry that <ref>'s changelog does not
changelog.py release-check <tag> a release tag: v<semver>, with a dated, non-empty section
changelog.py notes <version> that version's section, for the release page
changelog.py notes <version> that version's section, else [Unreleased], for the release page

Exits 1 with one line per problem. Used by changelog.yml and release.yml; see docs/RELEASING.md.
"""
Expand Down Expand Up @@ -90,20 +89,6 @@ def section(version):
return None


def release_check(tag):
problems = check()
m = re.fullmatch(r"v(" + SEMVER + r")" + BUILD, tag)
if not m:
return problems + [f"tag {tag} is not v<semantic version>, e.g. v1.2.3 or v1.3.0-beta.1"]
version = m.group(1) # build metadata names a build, not a release, so the section is the version's
body = section(version)
if body is None:
problems.append(f"CHANGELOG.md has no '## [{version}] - YYYY-MM-DD' section")
elif not has_entries(body):
problems.append(f"CHANGELOG.md's {version} section has no entries")
return problems


def unreleased_entries(text):
entries, inside = [], False
for line in text.splitlines():
Expand Down Expand Up @@ -131,14 +116,13 @@ def main(argv):
problems = check()
elif len(argv) == 3 and argv[1] == "unreleased-grew":
problems = unreleased_grew(argv[2])
elif len(argv) == 3 and argv[1] == "release-check":
problems = release_check(argv[2])
elif len(argv) == 3 and argv[1] == "notes":
# Prints nothing when neither has entries; release.yml then lets GitHub write the notes.
body = section(argv[2])
if body is None or not has_entries(body):
print(f"CHANGELOG.md has no entries for {argv[2]}", file=sys.stderr)
return 1
print("\n".join(body).strip())
body = next((b for h, _, b in sections() if h == "## [Unreleased]"), [])
if has_entries(body):
print("\n".join(body).strip())
return 0
else:
print(__doc__, file=sys.stderr)
Expand Down
Loading