diff --git a/.github/workflows/pub_publish.yml b/.github/workflows/pub_publish.yml new file mode 100644 index 0000000..e3aa534 --- /dev/null +++ b/.github/workflows/pub_publish.yml @@ -0,0 +1,47 @@ +name: Publish pub.dev package + +on: + push: + tags: + - 'intentcall_*-v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag to preflight, for example intentcall_core-v0.1.1' + required: true + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: pub-dev-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: pub.dev + timeout-minutes: 30 + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - uses: dart-lang/setup-dart@v1 + + - name: Workspace dependencies + run: dart pub get + + - name: Release preflight + run: dart run tool/intentcall/bin/intentcall.dart publish-tag --tag "$RELEASE_TAG" --skip-existing + + - name: Publish package + if: github.event_name == 'push' + run: dart run tool/intentcall/bin/intentcall.dart publish-tag --tag "$RELEASE_TAG" --execute --skip-existing diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..feb3aab --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,21 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - name: Release Please + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..fc4e3b0 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,12 @@ +{ + "packages/intentcall_schema": "0.1.0", + "packages/intentcall_core": "0.1.0", + "packages/intentcall_session": "0.1.0", + "packages/intentcall_mcp": "0.1.0", + "packages/intentcall_webmcp": "0.1.0", + "packages/intentcall_apple": "0.1.0", + "packages/intentcall_android": "0.1.0", + "packages/intentcall_codegen": "0.1.0", + "packages/intentcall_platform": "0.1.0", + "packages/intentcall_testing": "0.1.0" +} diff --git a/PUBLISHING.md b/PUBLISHING.md index 7f5e5f3..90d5871 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -4,6 +4,55 @@ Status: `0.1.0` has been published to pub.dev for the `intentcall_*` package train. Use this runbook for future releases; keep the first-publish checks only as historical/diagnostic guidance. +## Automatic release and publish flow + +IntentCall uses manifest-driven Release Please plus tag-triggered pub.dev +publishing: + +1. Conventional commits land on `main`. +2. `.github/workflows/release-please.yml` opens or updates one release PR from + `release-please-config.json` and `.release-please-manifest.json`. +3. The Release Please config links every publishable `intentcall_*` component + into one package train, including `intentcall_session`, so versions remain + synchronized. +4. Merging the release PR creates component tags such as + `intentcall_core-v0.1.1`. +5. `.github/workflows/pub_publish.yml` runs for each `intentcall_*-v*` tag and + publishes the package named by that tag. The workflow is skip-existing safe, + so rerunning a tag does not republish an already visible package version. + +This mirrors the `mcp_flutter` release pattern: Release Please owns version and +changelog generation, while the native repo publish script owns pub.dev +preflight and publishing. + +## Required GitHub and pub.dev configuration + +- Add a repository secret named `RELEASE_PLEASE_TOKEN` with permission to create + releases and tags that trigger follow-up workflows. If Release Please falls + back to `GITHUB_TOKEN`, GitHub can suppress the downstream tag-triggered + publish workflow. +- Create a GitHub Actions environment named `pub.dev`. Add required reviewers + there if release publishing should be gated. +- On pub.dev, enable automated publishing for every existing package in the + train. Use repository `Arenukvern/intentcall`, environment `pub.dev`, and the + package-specific tag pattern: + +```text +intentcall_schema-v{{version}} +intentcall_core-v{{version}} +intentcall_session-v{{version}} +intentcall_mcp-v{{version}} +intentcall_webmcp-v{{version}} +intentcall_apple-v{{version}} +intentcall_android-v{{version}} +intentcall_codegen-v{{version}} +intentcall_platform-v{{version}} +intentcall_testing-v{{version}} +``` + +No long-lived pub.dev token is required in GitHub Actions. The publish workflow +uses GitHub OIDC through `dart-lang/setup-dart`. + ## Prerequisites - `dart pub` logged in (`dart pub token add https://pub.dev`) @@ -16,7 +65,7 @@ checks only as historical/diagnostic guidance. 1. `intentcall_schema` 2. `intentcall_core` 3. `intentcall_session` -4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_gemma`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` +4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` 5. `intentcall_platform` (Flutter plugin — may need `flutter pub publish`) 6. `intentcall_testing` @@ -32,11 +81,17 @@ just publish-preflight # Validate all packages (CI uses this) just publish-dry-run +# Validate one package tag the way automated publishing does +just publish-tag-dry-run intentcall_session-v0.1.0 + # Diagnostic only while release-critical files are dirty; still fails archive/content errors just publish-dry-run-ignore-warnings # After credentials are configured just publish-execute + +# CI normally runs this from .github/workflows/pub_publish.yml on tag push +just publish-tag-execute intentcall_session-v0.1.0 ``` For a brand-new package name, treat `just publish-preflight-first` as the release desk: @@ -54,6 +109,9 @@ For `intentcall_platform`, if `dart pub publish` fails on Flutter constraints, r cd packages/intentcall_platform && flutter pub publish --dry-run ``` +`intentcall_gemma` is an example-only workspace package and is marked +`publish_to: none`; do not include it in the pub.dev release train. + ## After publish (mcp_flutter cutover) Status: the initial `0.1.0` hosted cutover is complete in `mcp_flutter`. diff --git a/docs/DX_FAQ.mdx b/docs/DX_FAQ.mdx index 8b0d276..a61ca2e 100644 --- a/docs/DX_FAQ.mdx +++ b/docs/DX_FAQ.mdx @@ -36,6 +36,27 @@ just publish-dry-run # equivalent to: dart run tool/intentcall/bin/intentcall.dart publish-all ``` +**Q: How are IntentCall packages released automatically?** + +Release Please opens a release PR from `release-please-config.json` and +`.release-please-manifest.json`. The packages are linked as one synchronized +`intentcall_*` train, so merging the release PR creates component tags such as +`intentcall_core-v0.1.1`. Each tag triggers `.github/workflows/pub_publish.yml`, +which publishes the package named by the tag through pub.dev automated +publishing. + +**Q: How do I dry-run the package selected by a release tag?** + +```bash +just publish-tag-dry-run intentcall_session-v0.1.0 +# equivalent to: dart run tool/intentcall/bin/intentcall.dart publish-tag --tag intentcall_session-v0.1.0 --skip-existing +``` + +Pub.dev automated publishing must be enabled for every package with tag pattern +`-v{{version}}` and GitHub Actions environment `pub.dev`. The release +workflow should use `RELEASE_PLEASE_TOKEN`; using only `GITHUB_TOKEN` can create +tags without triggering the downstream publish workflow. + --- ## 🏭 Adding a dependency @@ -273,7 +294,7 @@ make check-intentcall-integration 1. `intentcall_schema` 2. `intentcall_core` 3. `intentcall_session` -4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_gemma`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` (parallel) +4. `intentcall_mcp`, `intentcall_webmcp`, `intentcall_apple`, `intentcall_android`, `intentcall_codegen` (parallel) 5. `intentcall_platform` 6. `intentcall_testing` @@ -299,6 +320,9 @@ just publish-execute This repo uses [release-please](https://github.com/googleapis/release-please). Merge a release PR generated by release-please, then run the publish command above. +`intentcall_gemma` stays in the workspace as an example-only adapter package. It +is marked `publish_to: none` and is not part of the pub.dev release train. + --- ## 🔗 Local sibling repo path overrides diff --git a/justfile b/justfile index 20baa6f..06fa9d0 100644 --- a/justfile +++ b/justfile @@ -33,6 +33,14 @@ publish-preflight-first: publish-execute: dart run tool/intentcall/bin/intentcall.dart publish-all --execute +# Dry-run the package selected by a release tag, for example intentcall_core-v0.1.1 +publish-tag-dry-run tag: + dart run tool/intentcall/bin/intentcall.dart publish-tag --tag {{tag}} --skip-existing + +# Publish the package selected by a release tag (CI-only in normal release flow) +publish-tag-execute tag: + dart run tool/intentcall/bin/intentcall.dart publish-tag --tag {{tag}} --execute --skip-existing + # Check for local IntentCall path dependencies in publishable packages check-path-deps: dart run tool/intentcall/bin/intentcall.dart check-path-deps diff --git a/packages/intentcall_gemma/CHANGELOG.md b/packages/intentcall_gemma/CHANGELOG.md index dddf3d3..35687b9 100644 --- a/packages/intentcall_gemma/CHANGELOG.md +++ b/packages/intentcall_gemma/CHANGELOG.md @@ -4,3 +4,4 @@ - First pre-release of the optional Gemma adapter for IntentCall experiments. - Includes function-registration plumbing over the shared IntentCall registry. +- Marked as an example-only workspace package, not a pub.dev publish target. diff --git a/packages/intentcall_gemma/README.md b/packages/intentcall_gemma/README.md index f9b6b7b..030123c 100644 --- a/packages/intentcall_gemma/README.md +++ b/packages/intentcall_gemma/README.md @@ -3,4 +3,13 @@ # intentcall_gemma -Example-only `GemmaPublishAdapter` for on-device Gemma experiments (not product-wired). \ No newline at end of file +Example-only `GemmaPublishAdapter` for on-device Gemma experiments. + +This package is intentionally not published to pub.dev. It remains in the +workspace as executable reference code for mapping IntentCall tool registrations +into Gemma-style function definitions, but it is not a supported product adapter +or part of the hosted IntentCall package train. + +Use it as a small implementation sketch when building a concrete on-device +Gemma bridge. Product packages should copy the relevant adapter shape into their +own runtime integration and own the model/runtime policy there. diff --git a/packages/intentcall_gemma/pubspec.yaml b/packages/intentcall_gemma/pubspec.yaml index 287bd36..a26825d 100644 --- a/packages/intentcall_gemma/pubspec.yaml +++ b/packages/intentcall_gemma/pubspec.yaml @@ -1,5 +1,6 @@ name: intentcall_gemma -description: PRE-RELEASE — Example Gemma publish adapter for intentcall (optional on-device bridge). +publish_to: none +description: Example-only Gemma adapter for IntentCall experiments; not published to pub.dev. version: 0.1.0 license: MIT repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_gemma diff --git a/packages/intentcall_session/CHANGELOG.md b/packages/intentcall_session/CHANGELOG.md index 99e0bdc..ecec6f5 100644 --- a/packages/intentcall_session/CHANGELOG.md +++ b/packages/intentcall_session/CHANGELOG.md @@ -5,3 +5,5 @@ - First pre-release of IntentCall runtime session persistence and invocation helpers. - Includes file-backed session state, state locking, safe writes, session lifecycle management, and an `AgentRegistry` session executor. +- Documents the public session, invocation, and snapshot flow with a runnable + package example. diff --git a/packages/intentcall_session/README.md b/packages/intentcall_session/README.md index f93d026..cbfa92b 100644 --- a/packages/intentcall_session/README.md +++ b/packages/intentcall_session/README.md @@ -6,6 +6,7 @@ IntentCall runtime sessions for commandable tools and apps. Use this package when a CLI, MCP server, app host, or agent tool needs to keep a durable attachment to a live runtime before invoking IntentCall registry entries. +See `example/session_example.dart` for a complete runnable in-memory example. This package owns reusable runtime persistence mechanics: @@ -55,6 +56,11 @@ final result = await manager.startSession( ); ``` +The connector is implemented by the host. A minimal connector only needs to +resolve an endpoint display string and report any target-selection diagnostics. +The package does not open sockets or know about Flutter, MCP, devices, browsers, +or daemons by itself. + ## Invoke through a session ```dart @@ -96,6 +102,16 @@ Hosts own how snapshots are produced. For example, Flutter MCP has a command snapshot service that executes its command catalog and stores the resulting JSON through this package. +## Run the example + +```bash +dart run packages/intentcall_session/example/session_example.dart +``` + +The example creates a fake connector, starts a persisted session, invokes an +`AgentRegistry` entry through that session, writes two JSON snapshots, and prints +a structural diff. + ## Boundaries `intentcall_session` is not a broker facade. A product broker can compose this diff --git a/packages/intentcall_session/example/session_example.dart b/packages/intentcall_session/example/session_example.dart new file mode 100644 index 0000000..d5333a6 --- /dev/null +++ b/packages/intentcall_session/example/session_example.dart @@ -0,0 +1,118 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; +import 'package:intentcall_session/intentcall_session.dart'; + +Future main() async { + final tempDir = Directory.systemTemp.createTempSync( + 'intentcall_session_example_', + ); + + final registry = InMemoryAgentRegistry() + ..register( + AgentCallEntry.tool( + namespace: 'debug', + name: 'select', + description: 'Select an object in the active runtime.', + inputSchema: const { + 'type': 'object', + 'properties': { + 'id': {'type': 'string'}, + }, + 'required': ['id'], + }, + handler: (final args) => + AgentResult.success(data: {'selected': args['id']}), + ).toRegistration(), + ); + + final sessions = IntentSessionManager( + connector: ExampleConnector(endpoint: 'ws://127.0.0.1:8181/ws'), + stateStore: StateStore(path: '${tempDir.path}/session_state.json'), + ); + await sessions.load(); + + final start = await sessions.startSession( + const IntentSessionStartRequest( + sessionId: 'debug', + mode: IntentSessionConnectionMode.uri, + uri: 'ws://127.0.0.1:8181/ws', + ), + ); + print('started: ${start.data}'); + + final executor = IntentSessionExecutor( + sessions: sessions, + registry: registry, + ); + final result = await executor.invoke( + sessionId: 'debug', + qualifiedName: 'debug_select', + arguments: const {'id': 'node-7'}, + ); + print('invoked: ${result.data}'); + + final snapshots = IntentSnapshotStore( + snapshotsDir: '${tempDir.path}/snapshots', + ); + await snapshots.saveSnapshot( + id: 'before', + snapshot: const { + 'id': 'before', + 'createdAt': '2026-06-22T00:00:00.000Z', + 'selection': null, + }, + ); + await snapshots.saveSnapshot( + id: 'after', + snapshot: const { + 'id': 'after', + 'createdAt': '2026-06-22T00:00:01.000Z', + 'selection': 'node-7', + }, + ); + + final diff = await snapshots.diffSnapshots(fromId: 'before', toId: 'after'); + print('snapshot diff: ${diff['summary']}'); + + await tempDir.delete(recursive: true); +} + +final class ExampleConnector implements IntentSessionConnector { + ExampleConnector({required this.endpoint}); + + final String endpoint; + + @override + String? activeEndpointDisplay; + + @override + Map get lastSelectionDiagnostics => const { + 'source': 'example', + }; + + @override + Future> connect({ + final IntentSessionConnectionMode mode = IntentSessionConnectionMode.auto, + final String? targetId, + final String? uri, + final String? host, + final int? port, + final bool forceReconnect = false, + }) async { + activeEndpointDisplay = uri ?? endpoint; + return { + 'connected': true, + 'mode': mode.name, + 'reusedConnection': !forceReconnect, + }; + } + + @override + Future disconnect() async { + activeEndpointDisplay = null; + } +} diff --git a/packages/intentcall_session/pubspec.yaml b/packages/intentcall_session/pubspec.yaml index 8c0dc49..ea86d2f 100644 --- a/packages/intentcall_session/pubspec.yaml +++ b/packages/intentcall_session/pubspec.yaml @@ -1,5 +1,5 @@ name: intentcall_session -description: PRE-RELEASE — IntentCall runtime session persistence and invocation helpers. +description: Pre-release runtime session persistence, attachment, and invocation helpers for IntentCall. version: 0.1.0 license: MIT repository: https://github.com/Arenukvern/intentcall/tree/main/packages/intentcall_session diff --git a/release-please-config.json b/release-please-config.json index 484dcda..e0dd647 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,5 +1,25 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-component-in-tag": true, + "group-pull-request-title-pattern": "chore: release intentcall packages", + "plugins": [ + { + "type": "linked-versions", + "groupName": "intentcall package train", + "components": [ + "intentcall_schema", + "intentcall_core", + "intentcall_session", + "intentcall_mcp", + "intentcall_webmcp", + "intentcall_apple", + "intentcall_android", + "intentcall_codegen", + "intentcall_platform", + "intentcall_testing" + ] + } + ], "packages": { "packages/intentcall_schema": { "release-type": "dart", @@ -21,11 +41,6 @@ "package-name": "intentcall_webmcp", "component": "intentcall_webmcp" }, - "packages/intentcall_gemma": { - "release-type": "dart", - "package-name": "intentcall_gemma", - "component": "intentcall_gemma" - }, "packages/intentcall_apple": { "release-type": "dart", "package-name": "intentcall_apple", diff --git a/tool/intentcall/bin/intentcall.dart b/tool/intentcall/bin/intentcall.dart index ef7b8d4..6aeee60 100644 --- a/tool/intentcall/bin/intentcall.dart +++ b/tool/intentcall/bin/intentcall.dart @@ -10,7 +10,6 @@ const publishOrder = [ 'intentcall_session', 'intentcall_mcp', 'intentcall_webmcp', - 'intentcall_gemma', 'intentcall_apple', 'intentcall_android', 'intentcall_codegen', @@ -60,6 +59,25 @@ void main(List arguments) async { help: 'Diagnostic dry-run only: continue despite pub warnings such as dirty git state.', ), + ) + ..addCommand( + 'publish-tag', + ArgParser() + ..addOption( + 'tag', + help: + 'Release tag in the form -v, for example intentcall_core-v0.1.1.', + ) + ..addFlag( + 'execute', + negatable: false, + help: 'Execute publishing instead of dry-run preflight.', + ) + ..addFlag( + 'skip-existing', + negatable: false, + help: 'Skip when pub.dev already exposes this package version.', + ), ); ArgResults results; @@ -120,6 +138,21 @@ void main(List arguments) async { ); exit(code); + case 'publish-tag': + final cmdResults = results.command!; + final tag = + cmdResults['tag'] as String? ?? + Platform.environment['GITHUB_REF_NAME']; + final execute = cmdResults['execute'] as bool? ?? false; + final skipExisting = cmdResults['skip-existing'] as bool? ?? false; + final code = await runPublishTag( + repoRoot, + tag: tag, + dryRun: !execute, + skipExisting: skipExisting, + ); + exit(code); + default: printUsage(parser); exit(64); @@ -159,6 +192,9 @@ void printUsage(ArgParser parser) { print( ' publish-all Publish all workspace packages to pub.dev in order.', ); + print( + ' publish-tag Publish one package selected by a release tag.', + ); print('\nOptions:'); print(parser.usage); } @@ -536,6 +572,270 @@ Future runPublishAll( return 0; } +Future runPublishTag( + Directory repoRoot, { + required String? tag, + required bool dryRun, + required bool skipExisting, +}) async { + if (tag == null || tag.trim().isEmpty) { + stderr.writeln( + 'FAIL: publish-tag requires --tag or GITHUB_REF_NAME in the form -v.', + ); + return 64; + } + + final release = parsePackageReleaseTag(tag.trim()); + if (release == null) { + stderr.writeln( + 'FAIL: release tag "$tag" does not match an IntentCall package tag. Expected one of:', + ); + for (final pkg in publishOrder) { + stderr.writeln(' - $pkg-v'); + } + return 64; + } + + print( + '== IntentCall package release: ${release.package} ${release.version} ==', + ); + + final validateCode = await runValidate(repoRoot); + if (validateCode != 0) { + return validateCode; + } + + final staticCode = await runReleasePackageStaticCheck(repoRoot, release); + if (staticCode != 0) { + return staticCode; + } + + if (skipExisting && + await packageHasVersion(release.package, release.version)) { + print( + 'OK: pub.dev already exposes ${release.package} ${release.version}; skipping.', + ); + return 0; + } + + final packageDir = p.join(repoRoot.path, 'packages', release.package); + final isPlatform = release.package == 'intentcall_platform'; + final exec = isPlatform ? 'flutter' : 'dart'; + + if (dryRun) { + final args = ['pub', 'publish', '--dry-run', '--skip-validation']; + final code = await runCommand(exec, args, packageDir); + if (code != 0) { + stderr.writeln( + 'FAIL: publish preflight for ${release.package} failed with exit code $code', + ); + return code; + } + print('\nOK: publish-tag preflight complete.'); + return 0; + } + + final dependencyWaitCode = await waitForReleaseDependencies( + repoRoot, + release, + ); + if (dependencyWaitCode != 0) { + return dependencyWaitCode; + } + + var code = await runCommand(exec, [ + 'pub', + 'publish', + '--dry-run', + ], packageDir); + if (code != 0) { + stderr.writeln( + 'FAIL: strict publish dry-run for ${release.package} failed with exit code $code', + ); + return code; + } + + code = await runCommand(exec, ['pub', 'publish', '--force'], packageDir); + if (code != 0) { + stderr.writeln( + 'FAIL: publishing ${release.package} failed with exit code $code', + ); + return code; + } + + final waitCode = await waitForPubVersion(release.package, release.version); + if (waitCode != 0) { + return waitCode; + } + + print('\nOK: publish-tag complete.'); + return 0; +} + +PackageRelease? parsePackageReleaseTag(String tag) { + for (final pkg in publishOrder) { + final prefix = '$pkg-v'; + if (tag.startsWith(prefix)) { + final version = tag.substring(prefix.length); + if (isSemver(version)) { + return PackageRelease(package: pkg, version: version); + } + } + } + return null; +} + +bool isSemver(String version) => RegExp( + r'^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$', +).hasMatch(version); + +Future runReleasePackageStaticCheck( + Directory repoRoot, + PackageRelease release, +) async { + final packageDir = p.join(repoRoot.path, 'packages', release.package); + final pubspec = File(p.join(packageDir, 'pubspec.yaml')); + final changelog = File(p.join(packageDir, 'CHANGELOG.md')); + + if (!pubspec.existsSync()) { + stderr.writeln('FAIL: missing ${release.package}/pubspec.yaml'); + return 1; + } + final pubspecContent = await pubspec.readAsString(); + if (RegExp( + r'^publish_to:\s*none\b', + multiLine: true, + ).hasMatch(pubspecContent)) { + stderr.writeln('FAIL: ${release.package} is marked publish_to: none'); + return 1; + } + if (!RegExp( + '^version:\\s*${RegExp.escape(release.version)}(?:\\s|#|\$)', + multiLine: true, + ).hasMatch(pubspecContent)) { + stderr.writeln( + 'FAIL: ${release.package}/pubspec.yaml version does not match ${release.version}.', + ); + return 1; + } + if (RegExp( + r'^\s{4}(path|git):\s', + multiLine: true, + ).hasMatch(pubspecContent)) { + stderr.writeln( + 'FAIL: ${release.package}/pubspec.yaml contains a path/git dependency.', + ); + return 1; + } + + if (!changelog.existsSync()) { + stderr.writeln('FAIL: missing ${release.package}/CHANGELOG.md'); + return 1; + } + final changelogContent = await changelog.readAsString(); + if (!RegExp( + '(^#\\s+${RegExp.escape(release.version)}\\b|' + '^##\\s+\\[${RegExp.escape(release.version)}\\]|' + '^##\\s+${RegExp.escape(release.version)}\\b)', + multiLine: true, + ).hasMatch(changelogContent)) { + stderr.writeln( + 'FAIL: ${release.package}/CHANGELOG.md has no entry for ${release.version}.', + ); + return 1; + } + + print('OK: static release checks passed for ${release.package}.'); + return 0; +} + +Future waitForReleaseDependencies( + Directory repoRoot, + PackageRelease release, +) async { + final packageDir = p.join(repoRoot.path, 'packages', release.package); + final pubspec = File(p.join(packageDir, 'pubspec.yaml')); + final content = await pubspec.readAsString(); + final dependencies = sameTrainDependencies(content, release); + for (final dependency in dependencies) { + print( + 'Waiting for pub.dev to expose $dependency ${release.version} before publishing ${release.package}...', + ); + final code = await waitForPubVersion(dependency, release.version); + if (code != 0) { + return code; + } + } + return 0; +} + +List sameTrainDependencies( + String pubspecContent, + PackageRelease release, +) { + final dependencies = []; + for (final pkg in publishOrder) { + if (pkg == release.package) { + continue; + } + final pattern = RegExp( + '^\\s{2}${RegExp.escape(pkg)}:\\s*\\^${RegExp.escape(release.version)}(?:\\s|#|\$)', + multiLine: true, + ); + if (pattern.hasMatch(pubspecContent)) { + dependencies.add(pkg); + } + } + return dependencies; +} + +Future packageHasVersion(String package, String version) async { + final client = HttpClient(); + try { + final uri = Uri.https('pub.dev', '/api/packages/$package'); + final request = await client.getUrl(uri); + final response = await request.close(); + if (response.statusCode == HttpStatus.notFound) { + await response.drain(); + return false; + } + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + stderr.writeln( + 'FAIL: Unexpected pub.dev response for $package: HTTP ${response.statusCode}', + ); + throw StateError('Unexpected pub.dev response'); + } + + final body = await response.transform(SystemEncoding().decoder).join(); + return body.contains('"version":"$version"'); + } finally { + client.close(force: true); + } +} + +Future waitForPubVersion(String package, String version) async { + final waitSeconds = + int.tryParse(Platform.environment['PUB_PUBLISH_WAIT_SECONDS'] ?? '') ?? + 300; + final deadline = DateTime.now().add(Duration(seconds: waitSeconds)); + + while (DateTime.now().isBefore(deadline)) { + try { + if (await packageHasVersion(package, version)) { + print('OK: pub.dev exposes $package $version.'); + return 0; + } + } catch (_) { + return 1; + } + await Future.delayed(const Duration(seconds: 10)); + } + + stderr.writeln('FAIL: timed out waiting for $package $version on pub.dev.'); + return 1; +} + List buildPublishArgs({ required bool dryRun, required bool ignoreWarnings, @@ -563,3 +863,10 @@ Future runCommand( ); return process.exitCode; } + +final class PackageRelease { + const PackageRelease({required this.package, required this.version}); + + final String package; + final String version; +} diff --git a/tool/intentcall/test/publish_preflight_test.dart b/tool/intentcall/test/publish_preflight_test.dart index 8b00138..df0efcb 100644 --- a/tool/intentcall/test/publish_preflight_test.dart +++ b/tool/intentcall/test/publish_preflight_test.dart @@ -39,6 +39,44 @@ void main() { }); }); + group('release tag publishing', () { + test('parses package release tags', () { + final release = intentcall_cli.parsePackageReleaseTag( + 'intentcall_session-v0.1.2', + ); + + expect(release, isNotNull); + expect(release!.package, 'intentcall_session'); + expect(release.version, '0.1.2'); + }); + + test('rejects non-package release tags', () { + expect(intentcall_cli.parsePackageReleaseTag('v0.1.2'), isNull); + expect( + intentcall_cli.parsePackageReleaseTag('intentcall_session-0.1.2'), + isNull, + ); + }); + + test('detects same-train dependencies for publish waits', () { + final dependencies = intentcall_cli.sameTrainDependencies( + ''' +dependencies: + intentcall_core: ^0.2.0 + path: ^1.9.1 +dev_dependencies: + intentcall_testing: ^0.2.0 +''', + const intentcall_cli.PackageRelease( + package: 'intentcall_mcp', + version: '0.2.0', + ), + ); + + expect(dependencies, ['intentcall_core', 'intentcall_testing']); + }); + }); + group('runReleaseGitCleanCheck', () { test('passes when release-critical files are clean', () async { final repo = await _createGitRepo();