From 1037c10249e93ed6e1aed5a735b80f88d55ada29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 23:15:28 +0000 Subject: [PATCH 1/2] Release: drop the changelog version check A tag no longer needs a dated changelog section to build. The notes are the tag's section, else [Unreleased], else notes GitHub generates. The 0.1.2 section becomes 0.1.4: v0.1.2 and v0.1.3 have published releases and are immutable. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YByKyNUrQMBphzHhXneQEY --- .github/workflows/release.yml | 8 ++++++-- CHANGELOG.md | 4 ++-- README.md | 2 +- docs/RELEASING.md | 22 +++++++++++----------- tools/changelog.py | 26 +++++--------------------- 5 files changed, 25 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ca2e44c..3b3b6903 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,6 @@ jobs: 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." exit 1 @@ -142,8 +141,12 @@ jobs: with: persist-credentials: false + # The version's changelog section, else [Unreleased]; with neither, GitHub writes notes from the commits. - name: Release notes from CHANGELOG.md - run: python3 tools/changelog.py notes ${{ needs.version.outputs.version }} > release-notes.md + id: notes + run: | + python3 tools/changelog.py notes ${{ needs.version.outputs.version }} > release-notes.md + if [ -s release-notes.md ]; then echo "generate=false" >> "$GITHUB_OUTPUT"; else echo "generate=true" >> "$GITHUB_OUTPUT"; fi - name: Download all artifacts uses: actions/download-artifact@v4 @@ -157,6 +160,7 @@ jobs: with: name: SharpMUTerm ${{ needs.version.outputs.version }} body_path: release-notes.md + generate_release_notes: ${{ steps.notes.outputs.generate == 'true' }} prerelease: ${{ contains(needs.version.outputs.version, '-') }} draft: true files: artifacts/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e639e04..611e1efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index df6bba81..7fb56122 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/RELEASING.md b/docs/RELEASING.md index d4219641..fdf32828 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -7,7 +7,7 @@ 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 @@ -15,8 +15,8 @@ Newest first; an `## [Unreleased]` section on top; each release `## [X.Y.Z] - YY |---|---| | 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` 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`) | +| The GitHub release's notes are the tag's section, verbatim; without one, `[Unreleased]`; without either, notes GitHub generates from the commits | **Release** (`changelog.py notes`) | +| A tag whose release is already published builds nothing | **Release**, before anything is built | ## Versions @@ -26,28 +26,28 @@ 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] - ` and puts a fresh, empty - `## [Unreleased]` above it. Merge it once CI is green. +1. Optionally, open a PR that renames `## [Unreleased]` to `## [X.Y.Z] - ` and puts a fresh, empty + `## [Unreleased]` above it. Nothing requires it; without it the release notes are `[Unreleased]` as it stands. 2. Tag the merge commit and push the tag: ```bash git tag vX.Y.Z 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 + GitHub release, `SharpMUTerm X.Y.Z`, with the changelog notes above, 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. +A tag is never moved or reused, because the repository has immutable releases on. If a release goes wrong, tag +the next patch version 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. +published release is refused before the build. `v0.1.0` to `v0.1.3` +shipped no packages (the first failed the changelog check, the other three were published from the UI before the +workflow ran), so 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. diff --git a/tools/changelog.py b/tools/changelog.py index bc1421ba..6366ebad 100644 --- a/tools/changelog.py +++ b/tools/changelog.py @@ -3,8 +3,7 @@ changelog.py check the changelog's shape changelog.py unreleased-grew [Unreleased] has an entry that 's changelog does not - changelog.py release-check a release tag: v, with a dated, non-empty section - changelog.py notes that version's section, for the release page + changelog.py notes 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. """ @@ -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, 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(): @@ -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) From 2659f1e60d8663b9787cad5f685518bd670e0a3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 23:20:15 +0000 Subject: [PATCH 2/2] Release: build on a release published in the UI Publishing a release on the Releases page now runs the workflow, which builds the tag and attaches the four packages to that release. Notes left empty are filled from the changelog. The tag-push trigger and the draft step are gone; immutability is off on the repository. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YByKyNUrQMBphzHhXneQEY --- .github/workflows/release.yml | 66 ++++++++++++++--------------------- docs/RELEASING.md | 35 +++++++------------ 2 files changed, 38 insertions(+), 63 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b3b6903..06134e71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -31,17 +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 - 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, e.g. v1.2.3 or v1.3.0-beta.1" exit 1 fi version="${GITHUB_REF_NAME#v}" @@ -128,50 +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 - # The version's changelog section, else [Unreleased]; with neither, GitHub writes notes from the commits. - - name: Release notes from CHANGELOG.md - id: notes - run: | - python3 tools/changelog.py notes ${{ needs.version.outputs.version }} > release-notes.md - if [ -s release-notes.md ]; then echo "generate=false" >> "$GITHUB_OUTPUT"; else echo "generate=true" >> "$GITHUB_OUTPUT"; fi - - 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 - generate_release_notes: ${{ steps.notes.outputs.generate == 'true' }} - 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 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fdf32828..40d689e4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -15,8 +15,8 @@ Newest first; an `## [Unreleased]` section on top; each release `## [X.Y.Z] - YY |---|---| | 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 | -| The GitHub release's notes are the tag's section, verbatim; without one, `[Unreleased]`; without either, notes GitHub generates from the commits | **Release** (`changelog.py notes`) | -| A tag whose release is already published builds nothing | **Release**, before anything is built | +| A release tag is `v` | **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 @@ -27,27 +27,16 @@ minor bump and is named in the changelog. A prerelease (`0.2.0-beta.1`) is publi ## Cutting a release 1. Optionally, open a PR that renames `## [Unreleased]` to `## [X.Y.Z] - ` and puts a fresh, empty - `## [Unreleased]` above it. Nothing requires it; without it the release notes are `[Unreleased]` as it stands. -2. Tag the merge commit and push the tag: - ```bash - git tag vX.Y.Z - 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 notes above, 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 release goes wrong, tag -the next patch version 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. `v0.1.0` to `v0.1.3` -shipped no packages (the first failed the changelog check, the other three were published from the UI before the -workflow ran), so the first release with packages is 0.1.4. + `## [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.