diff --git a/.github/scripts/fix_melos_version_rewrite.dart b/.github/scripts/fix_melos_version_rewrite.dart new file mode 100644 index 000000000..0f5b68c2d --- /dev/null +++ b/.github/scripts/fix_melos_version_rewrite.dart @@ -0,0 +1,136 @@ +// melos's `version` command can leave a hosted dependency constraint +// mangled, e.g. `^1.2.50-beta.13 <2.0.0"`, when the original was a compound +// range rather than a bare caret -- it only replaces the leading token. +// Repairs the syntax without touching the surviving upper bound; anything +// starting with `^` that doesn't fit fails loudly rather than being guessed at. + +import 'dart:io'; + +import 'package:pub_semver/pub_semver.dart'; + +// Deliberately not restricted to the known corruption's exact tail shape, +// so a variant of it can't slip past silently -- see the fail-loudly branch. +final _caretLine = RegExp( + r'^(?[ \t]*[\w.-]+[ \t]*:[ \t]*)' + r'''(?["']?\^[^\r\n]*)$''', + multiLine: true, +); + +// What melos's rewrite always emits: a caret immediately followed by a +// valid version (pub_semver versions always start with a digit). +final _leadingCaretToken = RegExp(r'^\^[0-9][\w.\-+]*'); + +void main(List args) { + final root = Directory(args.isNotEmpty ? args[0] : '.'); + if (!root.existsSync()) { + stderr.writeln('::error::Root directory not found: ${root.path}'); + exitCode = 1; + return; + } + + final pubspecs = root + .listSync(recursive: true, followLinks: false) + .whereType() + .where((f) => f.path.endsWith('pubspec.yaml')) + .where((f) => !f.path.contains('${Platform.pathSeparator}build${Platform.pathSeparator}')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + var filesFixed = 0; + var constraintsFixed = 0; + var hadUnrepairable = false; + + for (final file in pubspecs) { + final original = file.readAsStringSync(); + final matches = _caretLine.allMatches(original).toList(); + if (matches.isEmpty) continue; + + var updated = original; + var fileChanged = false; + + for (final match in matches.reversed) { + final rawValue = match.namedGroup('value')!; + final logicalValue = _stripMatchedQuotes(rawValue); + + if (_tryParse(logicalValue) != null) continue; // already valid + + final tokenMatch = _leadingCaretToken.firstMatch(rawValue); + final tail = tokenMatch == null ? '' : rawValue.substring(tokenMatch.end); + + // A non-empty tail without a real operator (e.g. just a stray quote) + // isn't evidence of a surviving range -- don't invent an unbounded one. + String? fixedValue; + if (tokenMatch != null && + tail.isNotEmpty && + _tryParse(tokenMatch[0]!) != null && + RegExp(r'[<>=]').hasMatch(tail)) { + final quote = tail[tail.length - 1]; + if (quote == '"' || quote == "'") { + final candidate = '$quote>=${tokenMatch[0]!.substring(1)}$tail'; + if (_tryParse(_stripMatchedQuotes(candidate)) != null) { + fixedValue = candidate; + } + } + } + + if (fixedValue == null) { + stderr.writeln( + '::error::${file.path}: found unparseable constraint ' + '"$rawValue" that does not match the known melos corruption ' + 'pattern. Needs manual review.', + ); + hadUnrepairable = true; + continue; + } + + final replacement = '${match.namedGroup('prefix')}$fixedValue'; + updated = updated.replaceRange(match.start, match.end, replacement); + fileChanged = true; + constraintsFixed++; + stdout.writeln( + '${file.path}: repaired "$rawValue" -> "$fixedValue"', + ); + } + + if (fileChanged) { + file.writeAsStringSync(updated); + filesFixed++; + } + } + + if (hadUnrepairable) { + stderr.writeln( + '::error::One or more constraints looked corrupted but could not be ' + 'safely auto-repaired. Aborting so a human can look.', + ); + exitCode = 1; + return; + } + + if (constraintsFixed == 0) { + stdout.writeln('No corrupted dependency constraints found.'); + } else { + stdout.writeln( + 'Repaired $constraintsFixed constraint(s) across $filesFixed file(s).', + ); + } +} + +String _stripMatchedQuotes(String value) { + if (value.length >= 2) { + final first = value[0]; + final last = value[value.length - 1]; + if ((first == '"' || first == "'") && last == first) { + return value.substring(1, value.length - 1); + } + } + return value; +} + +VersionConstraint? _tryParse(String value) { + try { + return VersionConstraint.parse(value); + } on FormatException { + return null; + } +} diff --git a/.github/scripts/repair_and_retag.sh b/.github/scripts/repair_and_retag.sh new file mode 100755 index 000000000..aff320bd9 --- /dev/null +++ b/.github/scripts/repair_and_retag.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +# melos already committed and tagged the versioned/dependent packages. +# Repair its dependency-constraint rewrite (fix_melos_version_rewrite.dart), +# fold it into that same commit, and re-point whatever tags melos created -- +# rather than hardcode which packages get tagged. Shared by both release +# workflows so they can't drift apart. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +tags="$(git tag --points-at HEAD)" + +dart pub get +dart run "$script_dir/fix_melos_version_rewrite.dart" + +git add -u +git commit --amend --no-edit + +while IFS= read -r tag; do + [ -n "$tag" ] && git tag -f "$tag" HEAD +done <<< "$tags" diff --git a/.github/workflows/publish-ensemble-pubdev.yml b/.github/workflows/publish-ensemble-pubdev.yml index 21a8373b2..3101a66c8 100644 --- a/.github/workflows/publish-ensemble-pubdev.yml +++ b/.github/workflows/publish-ensemble-pubdev.yml @@ -17,38 +17,58 @@ jobs: publish: name: Publish ensemble runtime runs-on: ubuntu-latest - if: "!contains(github.ref_name, '-beta')" outputs: version: ${{ steps.validate-release-tag.outputs.version }} steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + # pub.dev only trusts publishes from a workflow run that a genuine git + # tag push triggered, so a beta's opt-in can't be passed through as a + # workflow input -- it has to travel on the tag itself. A beta tag + # only proceeds if release-beta-version.yml annotated it with the + # [publish-to-pubdev] marker; stable tags always proceed. - name: Validate release tag id: validate-release-tag run: | set -euo pipefail - if [[ ! "${{ github.ref_name }}" =~ ^ensemble-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Expected a stable ensemble tag like ensemble-v1.2.47." + TAG="${GITHUB_REF_NAME}" + + if [[ "$TAG" =~ ^ensemble-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + elif [[ "$TAG" =~ ^ensemble-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then + message="$(git tag -l -n1 --format='%(contents)' "$TAG")" + if [[ "$message" == *"[publish-to-pubdev]"* ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + else + echo "Beta tag not opted in to pub.dev publishing; skipping." + echo "should_publish=false" >> "$GITHUB_OUTPUT" + fi + else + echo "::error::Unexpected tag '$TAG'." exit 1 fi - echo "version=${GITHUB_REF_NAME#ensemble-v}" >> "$GITHUB_OUTPUT" - - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} + echo "version=${TAG#ensemble-v}" >> "$GITHUB_OUTPUT" - name: Setup Flutter SDK + if: steps.validate-release-tag.outputs.should_publish == 'true' uses: subosito/flutter-action@v2 with: flutter-version: "3.32.5" cache: true - name: Setup pub.dev credentials + if: steps.validate-release-tag.outputs.should_publish == 'true' uses: dart-lang/setup-dart@v1 - name: Build pub.dev example from starter + if: steps.validate-release-tag.outputs.should_publish == 'true' run: | set -euo pipefail @@ -194,6 +214,7 @@ jobs: PY - name: Publish to pub.dev + if: steps.validate-release-tag.outputs.should_publish == 'true' working-directory: modules/ensemble run: flutter pub publish --force diff --git a/.github/workflows/release-beta-version.yml b/.github/workflows/release-beta-version.yml index 57862682a..17431630e 100644 --- a/.github/workflows/release-beta-version.yml +++ b/.github/workflows/release-beta-version.yml @@ -5,8 +5,11 @@ # # Flow: # 1. Checkout branch, detach HEAD -# 2. melos version creates commit + tag on detached HEAD -# 3. Push only the tag (branch unchanged) +# 2. melos version creates commit + tags on detached HEAD +# 3. Repair and re-tag (.github/scripts/repair_and_retag.sh) +# 4. If publish_to_pubdev, mark the tag with [publish-to-pubdev] so +# publish-ensemble-pubdev.yml's native tag-push trigger knows to publish +# 5. Push only the ensemble-v tag (branch unchanged) name: Release Beta Version @@ -23,6 +26,11 @@ on: required: true type: string default: "1.2.47-beta.1" + publish_to_pubdev: + description: "Also publish this beta to pub.dev as a prerelease" + required: false + type: boolean + default: false concurrency: group: melos-beta-release-${{ github.repository }}-${{ inputs.branch }} @@ -77,6 +85,13 @@ jobs: fetch-depth: 0 token: ${{ secrets.RELEASE_TOKEN }} + # inputs.branch may lack these files or have a stale copy; pull both + # from wherever this workflow file itself came from (github.sha). + - name: Use release scripts from this workflow's own ref + run: | + git fetch origin ${{ github.sha }} --depth=1 + git checkout ${{ github.sha }} -- .github/scripts .github/workflows/publish-ensemble-pubdev.yml + - name: Configure Git run: | git config user.name "github-actions[bot]" @@ -100,9 +115,19 @@ jobs: # Not strictly necessary since we only push the tag, but keeps intent clear git checkout --detach HEAD - # Melos creates version commit + tag on detached HEAD melos version ensemble "${{ inputs.version }}" --yes + - name: Repair melos dependency-constraint rewrite + run: .github/scripts/repair_and_retag.sh + + # pub.dev only trusts a publish triggered by a genuine tag push, so the + # opt-in can't be passed as a workflow input to that workflow -- it has + # to travel on the tag itself. publish-ensemble-pubdev.yml checks for + # this marker before publishing a beta. + - name: Mark tag for pub.dev publishing + if: inputs.publish_to_pubdev + run: git tag -f -a "ensemble-v${{ inputs.version }}" -m "[publish-to-pubdev]" HEAD + - name: Push tag only run: | # Push only the tag - branch remains unchanged diff --git a/.github/workflows/release-melos-version.yml b/.github/workflows/release-melos-version.yml index e2849e3a3..a8888252c 100644 --- a/.github/workflows/release-melos-version.yml +++ b/.github/workflows/release-melos-version.yml @@ -1,6 +1,6 @@ # Automates Releasing a New Version -# melos version ensemble [version] -# git push --follow-tags origin main +# melos version -> repair and re-tag (.github/scripts/repair_and_retag.sh) +# -> push main + tags name: Release Ensemble Version @@ -30,6 +30,20 @@ jobs: fetch-depth: 0 token: ${{ secrets.RELEASE_TOKEN }} + # Only overlay files from github.sha if it's ahead of main -- otherwise + # a stale dispatch ref would silently revert main's own release files. + - name: Use release scripts from this workflow's own ref + run: | + set -euo pipefail + # Full history, not --depth=1: merge-base --is-ancestor can't + # correctly resolve ancestry across a shallow fetch boundary. + git fetch origin ${{ github.sha }} + if git merge-base --is-ancestor HEAD ${{ github.sha }}; then + git checkout ${{ github.sha }} -- .github/scripts .github/workflows/publish-ensemble-pubdev.yml + else + echo "::warning::Dispatched ref is not ahead of main; keeping main's own release files." + fi + - name: Configure Git run: | git config user.name "github-actions[bot]" @@ -128,6 +142,9 @@ jobs: --manual-version ensemble_bracket:${{ steps.resolve-version.outputs.version }} \ --yes + - name: Repair melos dependency-constraint rewrite + run: .github/scripts/repair_and_retag.sh + - name: Push branch and tags run: | git push origin main diff --git a/pubspec.yaml b/pubspec.yaml index 810fdbd6e..6531d3b3a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,3 +5,4 @@ environment: dev_dependencies: melos: ^3.4.0 + pub_semver: ^2.1.4