From b9d40e72f82ed5a04989360b34aec4bb3af78282 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 11:07:01 +0300 Subject: [PATCH] ci(release): check_release.sh and prepare_beta.sh; version-bump keeps a beta version; release branches are checked in CI (#1498) --- .github/workflows/ci.yml | 15 +++ .github/workflows/release.yml | 9 ++ .github/workflows/version-bump.yml | 28 ++++-- scripts/release/check_release.sh | 79 +++++++++++++++ scripts/release/prepare_beta.sh | 55 +++++++++++ test/packaging/release_scripts_test.dart | 119 +++++++++++++++++++++++ 6 files changed, 299 insertions(+), 6 deletions(-) create mode 100755 scripts/release/check_release.sh create mode 100755 scripts/release/prepare_beta.sh create mode 100644 test/packaging/release_scripts_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40e5f62f..11ed99a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,21 @@ jobs: - uses: actions/checkout@v4 - run: ./scripts/ci/test_sections.sh check + # A pull request from a release branch (release/X.Y.Z) must be taggable as + # it is: pubspec, CHANGELOG section and SemVer form (#1498). + release-check: + name: Release branch check + if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: check_release.sh for the pubspec version + run: | + VERSION=$(grep '^version:' pubspec.yaml | sed 's/^version:[[:space:]]*//; s/+.*//') + ./scripts/release/check_release.sh "$VERSION" + analyze: name: Code Analysis timeout-minutes: 20 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4866682d..ec926001 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Release checks (scripts/release/check_release.sh) + if: startsWith(github.ref, 'refs/tags/') + run: | + # The same checks as before tagging (#1498); here they only warn, the + # tag is already pushed. + if ! ./scripts/release/check_release.sh "${{ github.ref_name }}" --no-tag-check; then + echo "::warning::scripts/release/check_release.sh found problems with tag ${{ github.ref_name }} (see the log above)." + fi + - name: Warn when git tag and pubspec semver differ if: startsWith(github.ref, 'refs/tags/') run: | diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index ae4d82a8..e1e51fea 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -30,12 +30,28 @@ jobs: ref: main token: ${{ secrets.GITHUB_TOKEN }} - - name: Get current version + # A release branch carrying a beta (`0.5.0-beta.2+7`) keeps its version + # on merge: stripping `-beta.N` and bumping the patch would turn it into + # 0.5.1 (#1498). The bump happens on the next ordinary merge after the + # final version is in pubspec. + - name: Skip pre-release versions if: steps.check.outputs.merged == 'true' + id: pre + run: | + LINE=$(grep "^version:" pubspec.yaml | sed 's/^version:[[:space:]]*//') + if [[ "${LINE%%+*}" == *-* ]]; then + echo "::notice::pubspec has the pre-release version $LINE; not bumping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Get current version + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' id: current_version run: | LINE=$(grep "^version:" pubspec.yaml | sed 's/^version:[[:space:]]*//') - # Drop +build, then any -prerelease (e.g. 0.4.11-b → 0.4.11). + # Drop +build (a -prerelease was skipped above). CORE=$(echo "$LINE" | sed 's/+.*//; s/-.*//') if [[ "$LINE" == *'+'* ]]; then BUILD="${LINE##*+}" @@ -50,7 +66,7 @@ jobs: echo "current_build=$BUILD" >> "$GITHUB_OUTPUT" - name: Bump patch version - if: steps.check.outputs.merged == 'true' + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' id: bump run: | CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}" @@ -73,7 +89,7 @@ jobs: echo "new_build=$NEW_BUILD" >> "$GITHUB_OUTPUT" - name: Update pubspec.yaml - if: steps.check.outputs.merged == 'true' + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' run: | NEW_VERSION="${{ steps.bump.outputs.new_version }}" NEW_BUILD="${{ steps.bump.outputs.new_build }}" @@ -81,7 +97,7 @@ jobs: grep "^version:" pubspec.yaml - name: Sync AUR PKGBUILD pkgver with pubspec - if: steps.check.outputs.merged == 'true' + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' run: | NEW_VERSION="${{ steps.bump.outputs.new_version }}" sed -i "s/^pkgver=.*/pkgver=${NEW_VERSION}/" packaging/linux/aur/PKGBUILD @@ -90,7 +106,7 @@ jobs: grep ^pkgver packaging/linux/aur/PKGBUILD - name: Commit version bump - if: steps.check.outputs.merged == 'true' + if: steps.check.outputs.merged == 'true' && steps.pre.outputs.skip != 'true' run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" diff --git a/scripts/release/check_release.sh b/scripts/release/check_release.sh new file mode 100755 index 00000000..c2ba34ab --- /dev/null +++ b/scripts/release/check_release.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Checks that a release tag can be pushed: the things the Release workflow +# needs, checked before the tag instead of failing in "Publish GitHub Release" +# afterwards (#1498). +# +# scripts/release/check_release.sh 0.5.0-beta.1 +# scripts/release/check_release.sh v0.5.0 --no-tag-check # tag already exists (CI) +# +# Checks: the tag is SemVer (X.Y.Z or X.Y.Z-pre); pubspec's version without +# `+build` equals the tag without `v`; a pre-release has a build number in +# pubspec; CHANGELOG.md has `## []`; the tag is not used yet. +# Then prints what the workflow will do with it. Exits 1 on any problem. +# +# QUERYA_RELEASE_ROOT points at another checkout (tests). +set -euo pipefail + +usage() { echo "usage: $0 [--no-tag-check]" >&2; exit 2; } +[[ $# -ge 1 ]] || usage +TAG="$1" +TAG_CHECK=1 +[[ "${2:-}" == "--no-tag-check" ]] && TAG_CHECK=0 + +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ROOT="${QUERYA_RELEASE_ROOT:-$(cd "$SCRIPTS/.." && pwd)}" +VERSION="${TAG#v}" +problems=0 +fail() { echo "✗ $*"; problems=$((problems + 1)); } +ok() { echo "✓ $*"; } + +if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + ok "tag $TAG is a release version" +else + fail "tag $TAG is not X.Y.Z or X.Y.Z- (letters, digits and dots)" +fi +PRERELEASE=0 +[[ "$VERSION" == *-* ]] && PRERELEASE=1 + +LINE="$(grep '^version:' "$ROOT/pubspec.yaml" | sed 's/^version:[[:space:]]*//')" +SEMVER="${LINE%%+*}" +BUILD="" +[[ "$LINE" == *+* ]] && BUILD="${LINE##*+}" +if [[ "$SEMVER" == "$VERSION" ]]; then + ok "pubspec version $LINE matches the tag" +else + fail "pubspec says $LINE; set 'version: $VERSION+' (the artifacts are named after pubspec)" +fi +if (( PRERELEASE )) && ! [[ "$BUILD" =~ ^[0-9]+$ ]]; then + fail "a pre-release needs a build number in pubspec: 'version: $VERSION+'" +fi + +if "$SCRIPTS/extract-changelog-section.sh" "$VERSION" "$ROOT/CHANGELOG.md" >/dev/null 2>&1; then + ok "CHANGELOG.md has ## [$VERSION]" +else + fail "CHANGELOG.md has no '## [$VERSION]' section (Publish GitHub Release reads it)" +fi + +if (( TAG_CHECK )); then + if git -C "$ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + fail "tag $TAG already exists" + else + ok "tag $TAG is not used yet" + fi +fi + +echo +if (( PRERELEASE )); then + echo "The Release workflow will publish a GitHub pre-release (not 'latest'):" + echo " offered in the app only on the Beta / Early Access channel;" + echo " AUR and the APT / DNF repositories are skipped." +else + echo "The Release workflow will publish a stable release and make it 'latest':" + echo " offered on both channels; AUR and APT / DNF are published (when their secrets are set)." +fi + +if (( problems )); then + echo + echo "$problems problem(s): fix them before pushing the tag." + exit 1 +fi diff --git a/scripts/release/prepare_beta.sh b/scripts/release/prepare_beta.sh new file mode 100755 index 00000000..8a3daa61 --- /dev/null +++ b/scripts/release/prepare_beta.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Prepares beta N of version X.Y.Z on a release branch (#1498): +# - pubspec: version: X.Y.Z-beta.N+ +# - CHANGELOG: a "## [X.Y.Z-beta.N] - " heading under "## [Unreleased]", +# so what was unreleased becomes the beta's notes +# and prints the commands to check, commit and tag. Commits and pushes nothing. +# +# scripts/release/prepare_beta.sh 0.5.0 1 +# +# QUERYA_RELEASE_ROOT points at another checkout (tests). +set -euo pipefail + +[[ $# -eq 2 ]] || { echo "usage: $0 " >&2; exit 2; } +BASE="$1" +N="$2" +[[ "$BASE" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "error: '$BASE' is not X.Y.Z" >&2; exit 2; } +[[ "$N" =~ ^[1-9][0-9]*$ ]] || { echo "error: '$N' is not a beta number (1, 2, ...)" >&2; exit 2; } + +ROOT="${QUERYA_RELEASE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +VERSION="$BASE-beta.$N" +PUBSPEC="$ROOT/pubspec.yaml" +CHANGELOG="$ROOT/CHANGELOG.md" + +LINE="$(grep '^version:' "$PUBSPEC" | sed 's/^version:[[:space:]]*//')" +BUILD=0 +[[ "$LINE" == *+* ]] && BUILD="${LINE##*+}" +[[ "$BUILD" =~ ^[0-9]+$ ]] || BUILD=0 +NEW="$VERSION+$((BUILD + 1))" + +if grep -q "^## \[$VERSION\]" "$CHANGELOG"; then + echo "error: CHANGELOG.md already has ## [$VERSION]" >&2 + exit 1 +fi +if ! grep -q '^## \[Unreleased\]' "$CHANGELOG"; then + echo "error: CHANGELOG.md has no '## [Unreleased]' heading to put the beta under" >&2 + exit 1 +fi + +sed -i.bak "s/^version: .*/version: $NEW/" "$PUBSPEC" && rm -f "$PUBSPEC.bak" +TODAY="$(date +%Y-%m-%d)" +awk -v heading="## [$VERSION] - $TODAY" ' + { print } + /^## \[Unreleased\]/ && !done { print ""; print heading; done = 1 } +' "$CHANGELOG" > "$CHANGELOG.tmp" && mv "$CHANGELOG.tmp" "$CHANGELOG" + +cat < args) => Process.runSync( + 'bash', + ['scripts/release/$script', ...args], + environment: {'QUERYA_RELEASE_ROOT': root.path}, + ); + + const changelog = '# Changelog\n\n## [Unreleased]\n\n### Added\n- Beta thing\n\n' + '## [0.4.22] - 2026-10-01\n\n- Old\n'; + + setUp(() { + root = Directory.systemTemp.createTempSync('querya_release_'); + Process.runSync('git', ['init', '-q', root.path]); + }); + tearDown(() => root.deleteSync(recursive: true)); + + group('check_release.sh', () { + test('a prepared beta passes and is announced as a pre-release', () { + write('0.5.0-beta.1+6', '# Changelog\n\n## [0.5.0-beta.1] - 2026-10-18\n\n- x\n'); + final r = run('check_release.sh', ['0.5.0-beta.1']); + expect(r.exitCode, 0, reason: '${r.stdout}'); + expect(r.stdout, contains('pre-release')); + expect(r.stdout, contains('AUR and the APT / DNF repositories are skipped')); + }); + + test('a missing changelog section is named', () { + write('0.5.0-beta.1+6', changelog); + final r = run('check_release.sh', ['0.5.0-beta.1']); + expect(r.exitCode, 1); + expect(r.stdout, contains("no '## [0.5.0-beta.1]' section")); + }); + + test('a pubspec that does not match the tag fails', () { + write('0.4.22+5', '## [0.5.0]\n'); + final r = run('check_release.sh', ['v0.5.0']); + expect(r.exitCode, 1); + expect(r.stdout, contains('pubspec says 0.4.22+5')); + }); + + test('a beta needs a build number', () { + write('0.5.0-beta.1', '## [0.5.0-beta.1]\n'); + final r = run('check_release.sh', ['0.5.0-beta.1']); + expect(r.exitCode, 1); + expect(r.stdout, contains('needs a build number')); + }); + + test('a tag that is not SemVer fails', () { + write('0.5.0+1', '## [0.5.0]\n'); + expect(run('check_release.sh', ['0.5']).exitCode, 1); + expect(run('check_release.sh', ['0.5.0-beta_1']).exitCode, 1); + }); + + test('an existing tag fails unless --no-tag-check', () { + write('0.5.0+1', '## [0.5.0]\n'); + Process.runSync('git', ['-C', root.path, 'add', '.']); + Process.runSync('git', [ + '-C', root.path, '-c', 'user.email=t@t', '-c', 'user.name=t', + 'commit', '-qm', 'x', + ]); + Process.runSync('git', ['-C', root.path, 'tag', '0.5.0']); + + final r = run('check_release.sh', ['0.5.0']); + expect(r.exitCode, 1); + expect(r.stdout, contains('already exists')); + expect(run('check_release.sh', ['0.5.0', '--no-tag-check']).exitCode, 0); + }); + + test('a stable tag is announced as latest', () { + write('0.5.0+9', '## [0.5.0] - 2026-11-01\n'); + final r = run('check_release.sh', ['0.5.0']); + expect(r.exitCode, 0, reason: '${r.stdout}'); + expect(r.stdout, contains("make it 'latest'")); + }); + }); + + group('prepare_beta.sh', () { + test('sets the version, turns Unreleased into the beta section, then checks out', + () { + write('0.4.22+5', changelog); + final r = run('prepare_beta.sh', ['0.5.0', '1']); + expect(r.exitCode, 0, reason: '${r.stderr}'); + + expect(File(p.join(root.path, 'pubspec.yaml')).readAsStringSync(), + contains('version: 0.5.0-beta.1+6')); + final notes = File(p.join(root.path, 'CHANGELOG.md')).readAsStringSync(); + final unreleased = notes.indexOf('## [Unreleased]'); + final beta = notes.indexOf('## [0.5.0-beta.1]'); + expect(unreleased, isNonNegative); + expect(beta, greaterThan(unreleased)); + expect(notes.indexOf('- Beta thing'), greaterThan(beta)); + expect(r.stdout, contains('git tag -a 0.5.0-beta.1')); + + expect(run('check_release.sh', ['0.5.0-beta.1']).exitCode, 0); + }); + + test('refuses a section that already exists and bad arguments', () { + write('0.5.0-beta.1+6', '## [Unreleased]\n\n## [0.5.0-beta.1]\n'); + expect(run('prepare_beta.sh', ['0.5.0', '1']).exitCode, 1); + expect(run('prepare_beta.sh', ['0.5', '1']).exitCode, 2); + expect(run('prepare_beta.sh', ['0.5.0', '0']).exitCode, 2); + }); + }); +}