From b696b1908eacdcd2fac5fed0b7f4c020b1633b84 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:07:29 +0000 Subject: [PATCH] ci(release): open the next version line and clean up after production (#7381) --- .github/workflows/docs-release.yml | 16 ++- .github/workflows/post-release-cleanup.yml | 39 +++++-- .github/workflows/promote.yml | 15 ++- .github/workflows/version-bump.yml | 130 +++++++++++++++++++++ RELEASE_PROCESS.md | 7 +- docs/README.md | 6 +- scripts/bump-version-name.py | 64 ++++++++++ 7 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/version-bump.yml create mode 100755 scripts/bump-version-name.py diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml index ff18c198f2..fcb765e615 100644 --- a/.github/workflows/docs-release.yml +++ b/.github/workflows/docs-release.yml @@ -13,7 +13,7 @@ name: Docs Release # cycle would cost far more than it refreshes. # # These per-tag prerelease directories accumulate during a version cycle and are -# reaped by post-release-cleanup.yml once the production vX.Y.Z tag ships. +# reaped by post-release-cleanup.yml, which a production publish dispatches. # # The /main/ snapshot is owned by docs-deploy.yml and is left untouched here. # @@ -43,6 +43,11 @@ jobs: if: github.repository == 'meshtastic/Meshtastic-Android' runs-on: ubuntu-26.04 timeout-minutes: 45 + # actions: write is only for the cleanup dispatch; job permissions replace the + # workflow block, so contents: write is restated. + permissions: + contents: write + actions: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -148,3 +153,12 @@ jobs: - name: Publish to gh-pages run: scripts/docs/publish-to-gh-pages.sh build/pages_staging ${{ steps.stage.outputs.channels }} + + # The cleanup refuses to reap until /vX.Y.Z/ is on gh-pages, which the push above + # just made true. A dispatch is exempt from GITHUB_TOKEN's event suppression. + - name: Dispatch post-release cleanup + if: steps.version.outputs.is_production == 'true' + env: + GH_TOKEN: ${{ github.token }} + BASE_VERSION: ${{ steps.version.outputs.docs_version }} + run: gh workflow run post-release-cleanup.yml --ref main -f "base_version=$BASE_VERSION" -f confirm_deletion=true diff --git a/.github/workflows/post-release-cleanup.yml b/.github/workflows/post-release-cleanup.yml index 78dc4a5c76..2f8335010b 100644 --- a/.github/workflows/post-release-cleanup.yml +++ b/.github/workflows/post-release-cleanup.yml @@ -1,5 +1,7 @@ name: Post-Release Cleanup +# docs-release.yml dispatches this with confirm_deletion=true once a production tag's +# /vX.Y.Z/ is on gh-pages. A manual dispatch is the retry path and defaults to a dry run. on: workflow_dispatch: inputs: @@ -37,6 +39,18 @@ jobs: env: BASE_VERSION: ${{ github.event.inputs.base_version }} CONFIRM_DELETION: ${{ github.event.inputs.confirm_deletion }} + # Keeps the vX.Y.Z-* names on stdin whose X.Y.Z is at or below BASE_VERSION. Anything + # above it belongs to a later cycle, whose draft release promote.yml still needs. + AT_OR_BELOW: | + { + v = $0; sub(/^v/, "", v); sub(/-.*/, "", v) + split(v, a, "."); split(base, b, ".") + for (i = 1; i <= 3; i++) { + if (a[i] + 0 < b[i] + 0) { print; next } + if (a[i] + 0 > b[i] + 0) next + } + print + } steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -65,12 +79,12 @@ jobs: # v2.7.14-internal.N draft built while working toward 2.8.0). Scoping this # sweep to "v${BASE_VERSION}-*" misses exactly those, leaving stale drafts # behind forever. Once base_version has shipped as a stable release, every - # numbered internal/open/closed pre-release — regardless of which version - # it was tagged under — is superseded, so match on the pre-release SHAPE - # instead of a version prefix. + # numbered internal/open/closed pre-release at or below it is superseded, + # so match on the pre-release SHAPE capped by AT_OR_BELOW instead of a + # version prefix. TAG_PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-(internal|open|closed)\.[0-9]+$' - echo "Searching for pre-releases matching pattern '$TAG_PATTERN'." - RELEASES_TO_DELETE=$(gh release list --json tagName,isPrerelease,isDraft --limit 1000 | jq -r --arg pattern "$TAG_PATTERN" '.[] | select((.isPrerelease == true or .isDraft == true) and .tagName != null and (.tagName | test($pattern))) | .tagName') + echo "Searching for pre-releases matching pattern '$TAG_PATTERN' at or below $BASE_VERSION." + RELEASES_TO_DELETE=$(gh release list --json tagName,isPrerelease,isDraft --limit 1000 | jq -r --arg pattern "$TAG_PATTERN" '.[] | select((.isPrerelease == true or .isDraft == true) and .tagName != null and (.tagName | test($pattern))) | .tagName' | awk -v base="$BASE_VERSION" "$AT_OR_BELOW") if [ -z "$RELEASES_TO_DELETE" ]; then echo "No stale internal/open/closed pre-releases found." @@ -121,13 +135,14 @@ jobs: # dev cycle, including before the version-bump commit lands, so some may # still be named after the PRIOR release (e.g. v2.7.14-open.3 built while # working toward 2.8.0). Once /v${BASE_VERSION}/ is confirmed published - # above, every numbered open/closed snapshot is superseded regardless of - # which version it was tagged under — so match the snapshot-dir SHAPE - # instead of a version prefix. + # above, every numbered open/closed snapshot at or below it is superseded, + # so match the snapshot-dir SHAPE capped by AT_OR_BELOW instead of a + # version prefix. mapfile -t stale < <( find "$work" -maxdepth 1 -mindepth 1 -type d \ -regextype posix-extended \ - -regex ".*/v[0-9]+\.[0-9]+\.[0-9]+-(open|closed)\.[0-9]+" -printf '%f\n' | sort + -regex ".*/v[0-9]+\.[0-9]+\.[0-9]+-(open|closed)\.[0-9]+" -printf '%f\n' \ + | awk -v base="$BASE_VERSION" "$AT_OR_BELOW" | sort ) if [ ${#stale[@]} -eq 0 ]; then @@ -169,8 +184,8 @@ jobs: run: | set -euo pipefail # Same rationale as the release-cleanup step above: match the - # internal/open/closed pre-release tag SHAPE across all versions, not just - # tags prefixed with this dispatch's base_version. + # internal/open/closed pre-release tag SHAPE at or below base_version, + # not just tags prefixed with it. TAG_PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-(internal|open|closed)\.[0-9]+$' echo "Searching for any remaining remote pre-release tags matching pattern '$TAG_PATTERN'." @@ -181,7 +196,7 @@ jobs: REMOTE_TAGS=$(git ls-remote --tags origin "refs/tags/v*" | awk '{print $2}' | sed 's|refs/tags/||') # Some tags may have been deleted already by the previous 'release delete' step. - TAGS_TO_DELETE=$(grep -E "$TAG_PATTERN" <<<"$REMOTE_TAGS" || true) + TAGS_TO_DELETE=$(grep -E "$TAG_PATTERN" <<<"$REMOTE_TAGS" | awk -v base="$BASE_VERSION" "$AT_OR_BELOW" || true) if [ -z "$TAGS_TO_DELETE" ]; then echo "No dangling pre-release tags found." diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 88090ca97d..3f733726e3 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -357,6 +357,16 @@ jobs: TAG: ${{ inputs.final_tag }} run: gh workflow run msstore-publish.yml --ref main -f "tag=$TAG" + # Same suppression again: version-bump.yml opens the next patch line on main. + - name: Dispatch version bump + id: bump + if: ${{ inputs.channel == 'production' }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.final_tag }} + run: gh workflow run version-bump.yml --ref main -f "tag=$TAG" + # docs-release.yml's tag trigger never fires either: the release edit # above creates the tag with GITHUB_TOKEN. Run it on the tag ref, which # publishes /vX.Y.Z/ (plus the root and /api/ for production) or the @@ -543,6 +553,7 @@ jobs: DOCS: ${{ steps.docs.outcome }} WINGET: ${{ steps.winget.outcome }} MSSTORE: ${{ steps.msstore.outcome }} + BUMP: ${{ steps.bump.outcome }} STAMP: ${{ steps.stamp.outcome }} STAMP_PR: ${{ steps.stamp.outputs.pr_url }} run: | @@ -581,11 +592,13 @@ jobs: row "CHANGELOG.md stamp" "$STAMP ${STAMP_PR:+- $STAMP_PR}" row "winget dispatched" "$WINGET" row "Microsoft Store dispatched" "$MSSTORE" + row "Version bump dispatched" "$BUMP" + row "Post-Release Cleanup" "dispatched by Docs Release once /${TAG}/ is published" echo echo "Still by hand:" echo "- Play Console: the production rollout is staged; complete it there." echo "- Flathub: bump flathub/org.meshtastic.MeshtasticDesktop (tag, commit, gradle zip and sha256, flatpak-sources.json)." - echo "- Post-Release Cleanup: dispatch with confirm_deletion=true once Docs Release has published /${TAG}/." + echo "- Release notes: replace the placeholder the version bump PR wrote for the next line." fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000000..eedc8216cf --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,130 @@ +name: Bump Version Name + +# Opens the next patch line once a version ships to production, through a PR that +# merges itself: VERSION_NAME_BASE, its AppStream entry and the Play +# what's-new, all written by scripts/bump-version-name.py. +# +# promote.yml flips the release to production with GITHUB_TOKEN, whose events start no +# workflows, so it dispatches this one explicitly, as it does winget-publish.yml. The +# release trigger covers a release published by hand; workflow_dispatch is the retry path. +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: 'Production release tag that shipped (e.g., v2.8.2)' + required: true + type: string + +# The PR is opened and queued with CROWDIN_GITHUB_TOKEN, as scheduled-updates.yml does: +# GITHUB_TOKEN may not enable auto-merge on protected main, and its PRs run no checks. +permissions: + contents: read + pull-requests: read + +# A release event overlapping a dispatch would open two PRs for the same version. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + bump: + if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && !github.event.release.prerelease && !github.event.release.draft }} + runs-on: ubuntu-26.04-arm + timeout-minutes: 10 + env: + TAG: ${{ inputs.tag || github.event.release.tag_name }} + steps: + # A release event checks out the tag by default; the bump branches from main. + - name: Checkout main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + + - name: Resolve next version + id: next + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ ! "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::'$TAG' is not a production tag (vX.Y.Z)." + exit 1 + fi + SHIPPED="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + NEXT="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))" + BRANCH="automation/version-name-${NEXT}" + CURRENT=$(sed -n 's/^VERSION_NAME_BASE=//p' config.properties) + + # A hand bump that already landed (a minor line, say) wins over the patch default. + if [[ "$CURRENT" != "$SHIPPED" && "$(printf '%s\n%s\n' "$CURRENT" "$SHIPPED" | sort -V | tail -1)" == "$CURRENT" ]]; then + echo "::notice::main is already on $CURRENT, past $SHIPPED; nothing to bump." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + OPEN=$(gh pr list --state open --limit 200 --json number,title,headRefName \ + | jq -r --arg b "$BRANCH" --arg n "$NEXT" \ + '.[] | select(.headRefName != $b and (.title | test("VERSION_NAME_BASE to " + ($n | gsub("[.]"; "[.]")) + "([^0-9.]|$)"))) | .number') + if [[ -n "$OPEN" ]]; then + echo "::notice::A hand bump to $NEXT is already open: #$OPEN." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + { + echo "skip=false" + echo "shipped=$SHIPPED" + echo "next=$NEXT" + echo "branch=$BRANCH" + } >> "$GITHUB_OUTPUT" + + - name: Bump + if: ${{ steps.next.outputs.skip == 'false' }} + env: + NEXT: ${{ steps.next.outputs.next }} + run: python3 scripts/bump-version-name.py "$NEXT" + + # Creates the PR, or updates it on a retry. Pushed with the PAT, so pull-request.yml's + # AppStream and what's-new gates run on it like any other bump. + - name: Open the bump PR + id: pr + if: ${{ steps.next.outputs.skip == 'false' }} + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 + with: + token: ${{ secrets.CROWDIN_GITHUB_TOKEN }} + commit-message: 'chore: bump VERSION_NAME_BASE to ${{ steps.next.outputs.next }}' + title: 'chore: bump VERSION_NAME_BASE to ${{ steps.next.outputs.next }}' + body: | + Opens the ${{ steps.next.outputs.next }} line now that v${{ steps.next.outputs.shipped }} has shipped. + + The metainfo `` for ${{ steps.next.outputs.next }} is a placeholder. Rewrite it and re-run `python3 scripts/sync-play-changelog.py` before the ${{ steps.next.outputs.next }} internal cut, or it ships as the release Highlights and the Play what's-new. + branch: ${{ steps.next.outputs.branch }} + base: main + delete-branch: true + add-paths: | + config.properties + desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml + fastlane/metadata/android/en-US/changelogs/default.txt + labels: | + automation + skip-changelog + + # Same request as scheduled-updates.yml: the queue sets the merge method, and a PR + # that is already mergeable is refused the request and merges directly. + - name: Enable auto-merge + if: ${{ steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated' }} + env: + GH_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }} + run: | + PR_ID=$(gh pr view "$PR_NUMBER" --json id --jq .id) + QUERY=$(cat <<'GQL' + mutation($id: ID!) { + enablePullRequestAutoMerge(input: { pullRequestId: $id }) { + pullRequest { number autoMergeRequest { enabledAt } } + } + } + GQL + ) + gh api graphql -f query="$QUERY" -F id="$PR_ID" \ + || gh pr merge "$PR_NUMBER" diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 492f67f34e..df8f9a086c 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -27,7 +27,7 @@ The entire release process is managed by a single GitHub Action: **`Create or Pr - **Writes a checklist:** The promotion run's summary lists what it did, what it dispatched, and what is still done by hand. - **Deploys Desktop** *(internal releases)*: Builds native installers (DMG, MSI, EXE, DEB, RPM, AppImage) and Flatpak sources on a matrix of runners and attaches them to the GitHub Release. - **Changelog:** Both the GitHub Release notes and `CHANGELOG.md` are generated from merged PR labels, not raw commit messages — label PRs correctly (`enhancement`, `bugfix`, etc.) to keep them accurate. -- **Not part of this workflow:** Firmware/hardware/device-links lists and Crowdin translations are kept current by a separate hourly workflow, `scheduled-updates.yml` ("Scheduled Updates (Firmware, Hardware, Translations)"), which opens its own PR rather than committing directly and enables auto-merge on it, so it lands through the merge queue once its checks pass — it never runs as part of a release. `VERSION_NAME_BASE` in `config.properties` is likewise never written by automation: a maintainer bumps it by hand in an ordinary PR (e.g. "chore: bump VERSION_NAME_BASE to 2.8.2 (#6820)") before starting a release for a new base version, paired with a matching `` entry in `desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml` and its five `` URLs moved to `releases/download/v/` — a `pull-request.yml` check fails the PR if either is missing. `Create or Promote Release` only *reads* `VERSION_NAME_BASE`/`VERSION_CODE_OFFSET` from `config.properties` to compute the build's version name/code. +- **Not part of this workflow:** Firmware/hardware/device-links lists and Crowdin translations are kept current by a separate hourly workflow, `scheduled-updates.yml` ("Scheduled Updates (Firmware, Hardware, Translations)"), which opens its own PR rather than committing directly and enables auto-merge on it, so it lands through the merge queue once its checks pass — it never runs as part of a release. `VERSION_NAME_BASE` in `config.properties` moves to the next patch version after each production release: `promote.yml` dispatches `version-bump.yml`, which runs `scripts/bump-version-name.py` and opens a self-merging PR carrying the new `` entry in `desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml`, its five `` URLs moved to `releases/download/v/`, and `fastlane/metadata/android/en-US/changelogs/default.txt` rendered from that entry. `pull-request.yml` fails a bump PR missing any of them; the bot PR is opened with `CROWDIN_GITHUB_TOKEN`, so those checks run on it and the merge queue takes it. The entry's paragraph is a placeholder; replace it and re-run `scripts/sync-play-changelog.py` before the next internal cut, because it becomes the release Highlights and Play's "What's new". A minor or major line is a hand PR running the same script, and the workflow skips when `main` is already past the shipped version. `Create or Promote Release` only *reads* `VERSION_NAME_BASE`/`VERSION_CODE_OFFSET` from `config.properties` to compute the build's version name/code. ## Release Steps @@ -76,8 +76,9 @@ remains by hand. submission in Partner Center, and the pull request opened against `microsoft/winget-pkgs`. Each store workflow warns in its summary when its secrets are not set and it submitted nothing. 4. **Flathub** *(production only)*: bump the manifest in `flathub/org.meshtastic.MeshtasticDesktop` (see Flatpak below). -5. **Post-Release Cleanup** *(production only)*: once `Docs Release` has published `/vX.Y.Z/`, dispatch `post-release-cleanup.yml` with `confirm_deletion: true` to delete the cycle's pre-releases and tags. -6. **Merge:** If a `release/*` branch was used for stabilization (CI runs the same PR checks +5. **Post-Release Cleanup** *(production only)*: `Docs Release` dispatches `post-release-cleanup.yml` with `confirm_deletion: true` once it has published `/vX.Y.Z/`, deleting the pre-releases, tags and docs snapshots at or below `X.Y.Z`. Check that run; a manual dispatch is the retry and defaults to a dry run. +6. **Next version line** *(production only)*: the `version-bump.yml` PR bumps `VERSION_NAME_BASE` and merges itself. Replace its placeholder `` before the next internal cut. +7. **Merge:** If a `release/*` branch was used for stabilization (CI runs the same PR checks against PRs targeting `release/**` as it does for `main`), merge it back into `main` now that production has shipped. diff --git a/docs/README.md b/docs/README.md index 9c5d65cb44..8e4f0ccce8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,9 +70,9 @@ cycle and are not a documented channel. Prerelease snapshots accumulate during a version cycle so testers can read the docs for the exact build they are running. Once the production `vX.Y.Z` tag -ships, `/vX.Y.Z/` supersedes them and **Post-Release Cleanup** (run with -`base_version=X.Y.Z`) reaps the `vX.Y.Z-open.*` / `vX.Y.Z-closed.*` directories -along with the prerelease tags. That workflow defaults to a dry run. +ships, `/vX.Y.Z/` supersedes them, and once it is published Docs Release +dispatches **Post-Release Cleanup**, which reaps every open and closed directory +and prerelease tag at or below `X.Y.Z`. A manual dispatch defaults to a dry run. Only production releases own `/` and rebuild `/api/`. Prerelease tags publish their own directory only: `/api/` is unversioned and already refreshed by every diff --git a/scripts/bump-version-name.py b/scripts/bump-version-name.py new file mode 100755 index 0000000000..c5abd8a3e8 --- /dev/null +++ b/scripts/bump-version-name.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Open the next version line: VERSION_NAME_BASE plus everything pull-request.yml requires with it. + +That is the AppStream entry in metainfo.xml, its five URLs moved to the +new version's release assets, and the Play what's-new rendered from the entry by +sync-play-changelog.py. The entry's paragraph is a placeholder; rewrite it and re-run +sync-play-changelog.py before the internal cut, or it ships as the store text. + +Running it twice for the same version changes nothing the second time. + + python3 scripts/bump-version-name.py +""" +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CONFIG = REPO_ROOT / "config.properties" +METAINFO = REPO_ROOT / "desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml" +SYNC = REPO_ROOT / "scripts/sync-play-changelog.py" +PLACEHOLDER = "Stability and reliability fixes." + + +def bump_config(v: str) -> None: + text, n = re.subn(r"^VERSION_NAME_BASE=.*$", f"VERSION_NAME_BASE={v}", CONFIG.read_text(), flags=re.M) + if n != 1: + sys.exit(f"expected one VERSION_NAME_BASE line in {CONFIG.name}, found {n}") + CONFIG.write_text(text) + + +def bump_metainfo(v: str) -> None: + text = METAINFO.read_text() + if f' entry in {METAINFO.name} to insert above") + ind = first.group(1) + date = datetime.now(timezone.utc).date().isoformat() + entry = ( + f'{ind}\n' + f"{ind} \n" + f"{ind}

{PLACEHOLDER}

\n" + f"{ind}
\n" + f"{ind}
\n" + ) + text = text[: first.start()] + entry + text[first.start() :] + text = re.sub(r"([^<]*/releases/download/)v[^/<]+/", rf"\g<1>v{v}/", text) + METAINFO.write_text(text) + + +def main() -> int: + if len(sys.argv) != 2 or not re.fullmatch(r"\d+\.\d+\.\d+", sys.argv[1]): + sys.exit("usage: bump-version-name.py ") + v = sys.argv[1] + bump_config(v) + bump_metainfo(v) + subprocess.run([sys.executable, str(SYNC)], check=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())