From 8b3ea526cd7fb7dffd7f6f6932f3f89ad4c261c1 Mon Sep 17 00:00:00 2001 From: "Phat H. Nguyen" Date: Fri, 7 Aug 2026 12:09:27 +0200 Subject: [PATCH 1/2] ci: simplify release version automation --- .github/workflows/auto-bump-version.yml | 427 +++++------------------- 1 file changed, 86 insertions(+), 341 deletions(-) diff --git a/.github/workflows/auto-bump-version.yml b/.github/workflows/auto-bump-version.yml index 81f91f8..9717e7b 100644 --- a/.github/workflows/auto-bump-version.yml +++ b/.github/workflows/auto-bump-version.yml @@ -13,10 +13,6 @@ on: branches: - main -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - permissions: contents: read pull-requests: read @@ -27,7 +23,6 @@ jobs: runs-on: ubuntu-latest outputs: gh_pages_only: ${{ steps.classify.outputs.gh_pages_only }} - same_repo_pr: ${{ steps.classify.outputs.same_repo_pr }} steps: - name: Classify PR @@ -46,25 +41,26 @@ jobs: const ghPagesOnly = files.length > 0 && files.every((file) => file.filename.startsWith("gh-pages/")); - const sameRepoPr = - pr.head.repo.full_name === `${context.repo.owner}/${context.repo.repo}`; core.setOutput("gh_pages_only", ghPagesOnly ? "true" : "false"); - core.setOutput("same_repo_pr", sameRepoPr ? "true" : "false"); - validate-release-label: - name: Validate Release Label + prepare-release: + name: Prepare Release needs: classify-pr if: github.event.action != 'closed' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read steps: - - name: Skip validation for docs-only PRs + - name: Skip gh-pages-only PR if: needs.classify-pr.outputs.gh_pages_only == 'true' - run: echo "Release label validation skipped for gh-pages-only PR." + run: echo "Release workflow skipped for gh-pages-only PR." - - name: Require exactly one release label + - name: Resolve release label if: needs.classify-pr.outputs.gh_pages_only != 'true' + id: release env: LABELS_JSON: ${{ toJson(github.event.pull_request.labels) }} run: | @@ -72,15 +68,15 @@ jobs: import json import os - release_labels = [ - 'release:breaking', - 'release:feature', - 'release:bugfix', - 'release:patch', - 'release:improvement', - 'release:optimisation', - 'release:docs', - ] + release_labels = { + 'release:breaking': 'major', + 'release:feature': 'minor', + 'release:bugfix': 'patch', + 'release:patch': 'patch', + 'release:improvement': 'patch', + 'release:optimisation': 'patch', + 'release:docs': '', + } labels = {item['name'] for item in json.loads(os.environ['LABELS_JSON'])} selected = [label for label in release_labels if label in labels] @@ -96,77 +92,52 @@ jobs: print(f'Found: {selected or "none"}') raise SystemExit(1) - print(f'Using release label: {selected[0]}') - PY - - sync-release-version-commit: - name: Sync Release Version Commit - needs: classify-pr - if: github.event.action != 'closed' && needs.classify-pr.outputs.gh_pages_only != 'true' && needs.classify-pr.outputs.same_repo_pr == 'true' - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Read current release label - id: level - env: - LABELS_JSON: ${{ toJson(github.event.pull_request.labels) }} - run: | - python <<'PY' - import json - import os - - # release:docs is intentionally absent → no level → no version bump. - release_labels = { - 'release:breaking': 'major', - 'release:feature': 'minor', - 'release:bugfix': 'patch', - 'release:patch': 'patch', - 'release:improvement': 'patch', - 'release:optimisation': 'patch', - } - labels = {item['name'] for item in json.loads(os.environ['LABELS_JSON'])} - selected = [label for label in release_labels if label in labels] - level = release_labels[selected[0]] if len(selected) == 1 else '' - - if level: - print(f'Applying release bump level: {level}') - else: - print('No single release label selected, resetting version to the base version.') + label = selected[0] + level = release_labels[label] + print(f'Using release label: {label}') with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: + f.write(f'label={label}\n') f.write(f'level={level}\n') PY + - name: Require same-repo branch + if: needs.classify-pr.outputs.gh_pages_only != 'true' && github.event.pull_request.head.repo.full_name != github.repository + run: | + echo "::error::Release version automation can only push to branches in ${GITHUB_REPOSITORY}." + echo "::error::Please add the Cargo.toml/Cargo.lock version bump manually for fork PRs." + exit 1 + - name: Check out PR branch + if: needs.classify-pr.outputs.gh_pages_only != 'true' uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 token: ${{ secrets.PAT }} - - name: Fetch main and tags - run: git fetch origin "main:refs/remotes/origin/main" --tags - - - name: Determine desired version - id: version + - name: Sync release version commit + if: needs.classify-pr.outputs.gh_pages_only != 'true' env: - CURRENT_BUMP_LEVEL: ${{ steps.level.outputs.level }} + BUMP_LEVEL: ${{ steps.release.outputs.level }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - python <<'PY' + git fetch origin "main:refs/remotes/origin/main" --tags --force + + EXPECTED_VERSION=$(python <<'PY' import os import re import subprocess - current_level = os.environ['CURRENT_BUMP_LEVEL'] - def parse_version(raw): match = re.fullmatch(r'v?(\d+)\.(\d+)\.(\d+)', raw.strip()) if not match: return None return tuple(map(int, match.groups())) + def version_str(version): + return '.'.join(map(str, version)) + def bump(version, level): major, minor, patch = version if level == 'major': @@ -175,18 +146,18 @@ jobs: return (major, minor + 1, 0) return (major, minor, patch + 1) - manifest_raw = subprocess.run( + main_manifest = subprocess.run( ['git', 'show', 'origin/main:Cargo.toml'], check=True, capture_output=True, text=True, ).stdout - match = re.search(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', manifest_raw, re.M) + match = re.search(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', main_manifest, re.M) if not match: raise SystemExit('Could not find version in Cargo.toml on main') main_version = tuple(map(int, match.groups())) - + latest_tag_version = None tags = subprocess.run( ['git', 'tag', '--list', 'v*.*.*', '--sort=-v:refname'], check=True, @@ -194,45 +165,33 @@ jobs: text=True, ).stdout.splitlines() - latest_tag_version = None for tag in tags: parsed = parse_version(tag) if parsed is not None: latest_tag_version = parsed break - base_version = main_version - if latest_tag_version is not None: - base_version = max(main_version, latest_tag_version) - - desired_version = base_version - if current_level: - desired_version = bump(base_version, current_level) - - desired_version_str = '.'.join(map(str, desired_version)) - print(f'Base version: {".".join(map(str, base_version))}') - print(f'Desired version: {desired_version_str}') - - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: - f.write(f'desired_version={desired_version_str}\n') + base_version = max(main_version, latest_tag_version or main_version) + level = os.environ['BUMP_LEVEL'] + expected_version = bump(base_version, level) if level else base_version + print(version_str(expected_version)) PY + ) + echo "Expected version: ${EXPECTED_VERSION}" + export EXPECTED_VERSION - - name: Apply desired version - env: - DESIRED_VERSION: ${{ steps.version.outputs.desired_version }} - run: | python <<'PY' import os import re from pathlib import Path - desired_version = os.environ['DESIRED_VERSION'] + expected_version = os.environ['EXPECTED_VERSION'] cargo_toml = Path('Cargo.toml') cargo_toml_text = cargo_toml.read_text(encoding='utf-8') cargo_toml_text, toml_replacements = re.subn( r'^version\s*=\s*"\d+\.\d+\.\d+"', - f'version = "{desired_version}"', + f'version = "{expected_version}"', cargo_toml_text, count=1, flags=re.M, @@ -245,7 +204,7 @@ jobs: cargo_lock_text = cargo_lock.read_text(encoding='utf-8') cargo_lock_text, lock_replacements = re.subn( r'(\[\[package\]\]\nname = "localdesktop"\nversion = ")\d+\.\d+\.\d+(")', - rf'\g<1>{desired_version}\2', + rf'\g<1>{expected_version}\2', cargo_lock_text, count=1, ) @@ -254,39 +213,28 @@ jobs: cargo_lock.write_text(cargo_lock_text, encoding='utf-8') PY - - name: Commit and push version update - env: - DESIRED_VERSION: ${{ steps.version.outputs.desired_version }} - CURRENT_BUMP_LEVEL: ${{ steps.level.outputs.level }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: | if git diff --quiet -- Cargo.toml Cargo.lock; then + echo "Version files already match v${EXPECTED_VERSION}." exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add Cargo.toml Cargo.lock - - if [ -n "$CURRENT_BUMP_LEVEL" ]; then - COMMIT_MESSAGE="chore: set version to v${DESIRED_VERSION}" - else - COMMIT_MESSAGE="chore: reset version to v${DESIRED_VERSION}" - fi - - git commit -m "$COMMIT_MESSAGE" + git commit -m "chore: set version to v${EXPECTED_VERSION}" git push origin "HEAD:${HEAD_REF}" - publish-release-tag: - name: Publish Release Tag + tag-release: + name: Tag Release needs: classify-pr - if: github.event.action == 'closed' && github.event.pull_request.merged == true && needs.classify-pr.outputs.gh_pages_only != 'true' && needs.classify-pr.outputs.same_repo_pr == 'true' && !contains(github.event.pull_request.labels.*.name, 'release:docs') + if: github.event.action == 'closed' && github.event.pull_request.merged == true && needs.classify-pr.outputs.gh_pages_only != 'true' && !contains(github.event.pull_request.labels.*.name, 'release:docs') runs-on: ubuntu-latest permissions: contents: write + pull-requests: read steps: - - name: Require exactly one release label + - name: Resolve release label env: LABELS_JSON: ${{ toJson(github.event.pull_request.labels) }} run: | @@ -294,26 +242,23 @@ jobs: import json import os - # release:docs PRs never reach here (the job is skipped for them). - release_labels = [ + release_labels = { 'release:breaking', 'release:feature', 'release:bugfix', 'release:patch', 'release:improvement', 'release:optimisation', - ] + } labels = {item['name'] for item in json.loads(os.environ['LABELS_JSON'])} selected = [label for label in release_labels if label in labels] if len(selected) != 1: raise SystemExit( - 'Expected exactly one release label: ' - 'release:breaking, release:feature, release:bugfix, release:patch, ' - 'release:improvement, or release:optimisation' + 'Merged release PRs must have exactly one non-docs release label.' ) - print(f'Publishing tag for {selected[0]}') + print(f'Using release label: {selected[0]}') PY - name: Check out main @@ -323,239 +268,39 @@ jobs: fetch-depth: 0 token: ${{ secrets.PAT }} - - name: Extract version - id: version - run: echo "value=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[0].version')" >> "$GITHUB_OUTPUT" - - - name: Prepare release notes - id: notes + - name: Create release tag env: PR_BODY: ${{ github.event.pull_request.body }} PR_TITLE: ${{ github.event.pull_request.title }} run: | - python <<'PY' - import os - from pathlib import Path - - notes = (os.environ.get('PR_BODY') or '').strip() - if not notes: - notes = (os.environ.get('PR_TITLE') or '').strip() - - notes_path = Path(os.environ['RUNNER_TEMP']) / 'release-notes.md' - notes_path.write_text(f'{notes}\n', encoding='utf-8') - - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: - f.write(f'path={notes_path}\n') - PY - - - name: Create and push tag - env: - TAG: v${{ steps.version.outputs.value }} - run: | - if git ls-remote --exit-code --tags origin "$TAG" >/dev/null 2>&1; then - echo "Tag $TAG already exists, skipping." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -F "${{ steps.notes.outputs.path }}" - git push origin "refs/tags/$TAG" - - # Fork PRs can't carry their own version bump (the ruleset forbids direct - # pushes to main, and CI can't safely push to a fork). So once a fork PR - # merges, open an in-repo "release bump" PR that rides the normal same-repo - # flow: it carries the same release label, gets bumped + validated, and on - # merge the Publish job tags it. The v*.*.* tag — and therefore the APK - # build — only happens after this bump PR merges, i.e. on the merged fork - # code at its new version. - open-release-bump-pr: - name: Open Release Bump PR - needs: classify-pr - if: github.event.action == 'closed' && github.event.pull_request.merged == true && needs.classify-pr.outputs.gh_pages_only != 'true' && needs.classify-pr.outputs.same_repo_pr != 'true' && !contains(github.event.pull_request.labels.*.name, 'release:docs') - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - name: Resolve release label and level - id: release - env: - LABELS_JSON: ${{ toJson(github.event.pull_request.labels) }} - run: | - python <<'PY' - import json - import os - - release_labels = { - 'release:breaking': 'major', - 'release:feature': 'minor', - 'release:bugfix': 'patch', - 'release:patch': 'patch', - 'release:improvement': 'patch', - 'release:optimisation': 'patch', - } - labels = {item['name'] for item in json.loads(os.environ['LABELS_JSON'])} - selected = [label for label in release_labels if label in labels] - # release:docs never reaches this job; validation guarantees exactly one. - label = selected[0] - level = release_labels[label] - print(f'Release label: {label} (level: {level})') - - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: - f.write(f'label={label}\n') - f.write(f'level={level}\n') - PY - - - name: Check out main - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - token: ${{ secrets.PAT }} - - - name: Determine desired version - id: version - env: - CURRENT_BUMP_LEVEL: ${{ steps.release.outputs.level }} - run: | - git fetch origin --tags - python <<'PY' - import os + VERSION=$(python <<'PY' import re - import subprocess from pathlib import Path - current_level = os.environ['CURRENT_BUMP_LEVEL'] - - def parse_version(raw): - match = re.fullmatch(r'v?(\d+)\.(\d+)\.(\d+)', raw.strip()) - if not match: - return None - return tuple(map(int, match.groups())) - - def bump(version, level): - major, minor, patch = version - if level == 'major': - return (major + 1, 0, 0) - if level == 'minor': - return (major, minor + 1, 0) - return (major, minor, patch + 1) - - manifest_raw = Path('Cargo.toml').read_text(encoding='utf-8') - match = re.search(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', manifest_raw, re.M) + manifest = Path('Cargo.toml').read_text(encoding='utf-8') + match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', manifest, re.M) if not match: - raise SystemExit('Could not find version in Cargo.toml on main') - - main_version = tuple(map(int, match.groups())) - - tags = subprocess.run( - ['git', 'tag', '--list', 'v*.*.*', '--sort=-v:refname'], - check=True, - capture_output=True, - text=True, - ).stdout.splitlines() - - latest_tag_version = None - for tag in tags: - parsed = parse_version(tag) - if parsed is not None: - latest_tag_version = parsed - break - - base_version = main_version - if latest_tag_version is not None: - base_version = max(main_version, latest_tag_version) - - desired_version = bump(base_version, current_level) - desired_version_str = '.'.join(map(str, desired_version)) - print(f'Base version: {".".join(map(str, base_version))}') - print(f'Desired version: {desired_version_str}') - - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: - f.write(f'desired_version={desired_version_str}\n') + raise SystemExit('Could not find version in Cargo.toml') + print(match.group(1)) PY - - - name: Create bump branch - id: branch - env: - DESIRED_VERSION: ${{ steps.version.outputs.desired_version }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - BRANCH="release/v${DESIRED_VERSION}-pr-${PR_NUMBER}" - echo "name=${BRANCH}" >> "$GITHUB_OUTPUT" - - python <<'PY' - import os - import re - from pathlib import Path - - desired_version = os.environ['DESIRED_VERSION'] - - cargo_toml = Path('Cargo.toml') - cargo_toml_text = cargo_toml.read_text(encoding='utf-8') - cargo_toml_text, toml_replacements = re.subn( - r'^version\s*=\s*"\d+\.\d+\.\d+"', - f'version = "{desired_version}"', - cargo_toml_text, - count=1, - flags=re.M, - ) - if toml_replacements != 1: - raise SystemExit('Failed to update version in Cargo.toml') - cargo_toml.write_text(cargo_toml_text, encoding='utf-8') - - cargo_lock = Path('Cargo.lock') - cargo_lock_text = cargo_lock.read_text(encoding='utf-8') - cargo_lock_text, lock_replacements = re.subn( - r'(\[\[package\]\]\nname = "localdesktop"\nversion = ")\d+\.\d+\.\d+(")', - rf'\g<1>{desired_version}\2', - cargo_lock_text, - count=1, ) - if lock_replacements != 1: - raise SystemExit('Failed to update localdesktop package version in Cargo.lock') - cargo_lock.write_text(cargo_lock_text, encoding='utf-8') - PY - - if git diff --quiet -- Cargo.toml Cargo.lock; then - echo "No version change needed; skipping bump PR." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 + TAG="v${VERSION}" + + git fetch origin "+refs/tags/*:refs/tags/*" + if git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then + TAG_COMMIT=$(git rev-list -n 1 "${TAG}") + HEAD_COMMIT=$(git rev-parse HEAD) + if [ "$TAG_COMMIT" = "$HEAD_COMMIT" ]; then + echo "Tag ${TAG} already points to HEAD." + exit 0 + fi + + echo "::error::Tag ${TAG} already exists on a different commit." + exit 1 fi + printf '%s\n' "${PR_BODY:-$PR_TITLE}" > "${RUNNER_TEMP}/release-notes.md" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -b "${BRANCH}" - git add Cargo.toml Cargo.lock - git commit -m "chore: set version to v${DESIRED_VERSION}" - git push origin "${BRANCH}" - - - name: Open and auto-merge bump PR - if: steps.branch.outputs.skip != 'true' - env: - GH_TOKEN: ${{ secrets.PAT }} - DESIRED_VERSION: ${{ steps.version.outputs.desired_version }} - RELEASE_LABEL: ${{ steps.release.outputs.label }} - BRANCH: ${{ steps.branch.outputs.name }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BODY: ${{ github.event.pull_request.body }} - run: | - # The Publish job tags from this PR's body and build.yml turns the tag - # message into the GitHub Release notes, so mirror the merged fork PR's - # body here. - { - printf '%s\n\n' "${PR_BODY}" - printf -- '---\n_Release commit for #%s._\n' "${PR_NUMBER}" - } > "${RUNNER_TEMP}/bump-body.md" - - PR_URL=$(gh pr create \ - --base main \ - --head "${BRANCH}" \ - --title "chore: release v${DESIRED_VERSION} (#${PR_NUMBER})" \ - --body-file "${RUNNER_TEMP}/bump-body.md" \ - --label "${RELEASE_LABEL}") - echo "Opened bump PR: ${PR_URL}" - - gh pr merge "${PR_URL}" --auto --squash --delete-branch + git tag -a "${TAG}" -F "${RUNNER_TEMP}/release-notes.md" + git push origin "refs/tags/${TAG}" From 239172d62d07edacadd9c468e97fd0071f51420e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:10:12 +0000 Subject: [PATCH 2/2] chore: set version to v2.1.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5d7d72..301834f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2348,7 +2348,7 @@ dependencies = [ [[package]] name = "localdesktop" -version = "2.0.1" +version = "2.1.0" dependencies = [ "android-sdkmanager-rs", "android_logger", diff --git a/Cargo.toml b/Cargo.toml index 2cf9067..de1ff39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "localdesktop" -version = "2.0.1" +version = "2.1.0" edition = "2021" build = "build.rs" default-run = "build_apk"