diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ca2e44c..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,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, e.g. v1.2.3 or v1.3.0-beta.1" exit 1 fi version="${GITHUB_REF_NAME#v}" @@ -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 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..40d689e4 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`) | +| 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 @@ -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] - ` 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 - 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] - ` 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. 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)