Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .github/scripts/fix_melos_version_rewrite.dart
Original file line number Diff line number Diff line change
@@ -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'^(?<prefix>[ \t]*[\w.-]+[ \t]*:[ \t]*)'
r'''(?<value>["']?\^[^\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<String> 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<File>()
.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;
}
}
22 changes: 22 additions & 0 deletions .github/scripts/repair_and_retag.sh
Original file line number Diff line number Diff line change
@@ -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"
39 changes: 30 additions & 9 deletions .github/workflows/publish-ensemble-pubdev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
31 changes: 28 additions & 3 deletions .github/workflows/release-beta-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<version> tag (branch unchanged)

name: Release Beta Version

Expand All @@ -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 }}
Expand Down Expand Up @@ -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]"
Expand All @@ -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
Expand Down
21 changes: 19 additions & 2 deletions .github/workflows/release-melos-version.yml
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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]"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ environment:

dev_dependencies:
melos: ^3.4.0
pub_semver: ^2.1.4
Loading