Skip to content

Package Release Please #81

Package Release Please

Package Release Please #81

name: Package Release Please
# Conventional-commit-driven release automation for the four published npm packages in this monorepo
# (packages/loopover-mcp, packages/loopover-engine, packages/loopover-miner,
# packages/loopover-ui-kit). release-please maintains a Release PR per component that bumps its
# SemVer + regenerates its CHANGELOG.md from conventional commits touching that package's own
# subtree. Merging a Release PR tags the component (mcp-v<semver> / engine-v<semver> /
# miner-v<semver> / ui-kit-v<semver>, matching this repo's existing mcp-v* tag convention) and
# creates the GitHub Release. release-please then explicitly dispatches the matching publish
# workflow (publish-mcp.yml / publish-engine.yml / publish-miner.yml / publish-ui-kit.yml) via
# workflow_dispatch -- a tag-push trigger can't be used here: GITHUB_TOKEN-created tags don't fire
# push-triggered workflows (proven the hard way cutting v0.7.0 manually -- see #4132/#4135/#4154's
# tag-move dance).
#
# separate-pull-requests: true (release-please-config.json) -- unlike metagraphed's combined-PR
# default, these four packages have genuinely independent release cadences and separate publish
# targets/workflows, and bundling them would work against this repo's small-focused-PR convention.
#
# linked-versions plugin, group "engine-and-dependents" (release-please-config.json) -- the
# exception to the above: packages/loopover-mcp and packages/loopover-miner both carry a REAL
# runtime `dependencies` entry on packages/loopover-engine, so they are not actually independent of
# it. The node-workspace plugin bumps a dependent's `@loopover/engine` version range the moment
# engine's own version changes, but under plain separate-pull-requests: true that bump lands on the
# dependent's OWN separate branch/PR -- meaning the dependent's package.json ends up requiring an
# engine version that exists NOWHERE (not locally on that branch, since engine's bump lives on a
# different unmerged branch; not on npm either, since it hasn't published yet). `npm ci` then fails
# with ETARGET until a human notices and manually walks engine's release through to publish before
# updating the dependent's branch -- confirmed live across several mcp/miner release cycles
# (#7086/#7087/#7107/#7108/#7119/#7120/#7121; node-workspace's OWN `merge` option, tried first, does
# NOT override separate-pull-requests -- verified against release-please's manifest.ts and
# docs/manifest-releaser.md: separate-pull-requests is enforced as the final PR-splitting decision
# regardless of what plugins attempt internally). linked-versions is the mechanism that actually
# overrides per-component isolation, but ONLY for the named `components` in its group: engine's new
# version and its dependents' bumped ranges land together in ONE PR/commit, so npm workspaces
# resolves the dependency from the LOCAL checkout (always satisfies the range, regardless of npm
# registry publish timing or ordering) instead of hitting the ETARGET race. packages/loopover-ui-kit
# has no dependency edge to any other package here and is not in the group, so it keeps its fully
# independent release cadence untouched.
#
# Runs on every push to main, not just a schedule: release-please is idempotent (it recomputes
# purely from conventional-commit history since each component's last release tag), so the extra
# runs cost nothing when there's no new commit or merged Release PR to act on. The push trigger is
# not just about refreshing the Release PR's proposed diff sooner -- it's what actually completes a
# release: merging a component's Release PR only tags it, creates the GitHub Release, and dispatches
# the matching publish workflow on release-please's THIS run, so without a push trigger a merge sits
# fully un-published until the next cron tick (confirmed live: mcp-v3.1.0's Release PR merged 8
# minutes after a cron run had already passed, leaving it tagged-in-package.json-but-unpublished for
# what would have been up to 2 days). The schedule stays as a redundant safety net in case a
# push-triggered run is ever skipped/fails; workflow_dispatch stays available for an on-demand run.
on:
push:
branches:
- main
schedule:
- cron: "0 16 */2 * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: mcp-release-please
cancel-in-progress: false
# The release-please action can create public tags and GitHub Releases before it dispatches
# the package publish workflows. Keep the whole job behind the protected release environment
# so those release artifacts have the same approval boundary as the npm publish jobs.
jobs:
release-please:
runs-on: ubuntu-latest
environment: release
timeout-minutes: 15
permissions:
contents: write
pull-requests: write
actions: write # dispatch publish-mcp.yml / publish-engine.yml / publish-miner.yml / publish-ui-kit.yml after a release
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
# Authenticates as a real account (via RELEASE_PLEASE_TOKEN, a fine-grained PAT) rather than the
# default GITHUB_TOKEN. GitHub Apps like github-actions[bot] aren't tracked as repo collaborators,
# so PRs/commits it authors were repeatedly re-flagged as needing manual workflow-run approval on
# every push, even with the least-restrictive fork-PR-approval setting -- a real collaborator
# identity doesn't hit that gate. This also sidesteps the same GITHUB_TOKEN recursion-prevention
# rule noted below for tag pushes.
- name: Run release-please
id: release
uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# release-please's extra-files JSON-path updater doesn't reliably reach package-lock.json's
# per-workspace version fields (keys containing "/", nested under a manifest-mode component
# block) -- confirmed empirically (both the mcp-v0.7.0 dry run's precursor and the first two
# engine-v0.2.0 dry runs left it stale, breaking npm ci with "Missing: <pkg>@<version> from
# lock file"). Patches it directly on whichever release branch(es) release-please just
# created/updated, using the same branch naming convention its own commits already rely on.
#
# Also needs packages/loopover-miner/expected-engine.version bumped to match
# packages/loopover-engine/package.json's new version whenever a branch bumps engine:
# release-please's engine component only ever touches files under packages/loopover-engine/**,
# so it can never update that cross-package pin itself -- scripts/check-engine-parity.ts's
# checkMinerEngineVersionPinSync would otherwise fail on every release that bumps engine
# (confirmed live on the engine-v3.1.0 release PR, #5807).
#
# Branches to check are content-derived (git ls-remote --heads matching the release-please
# branch prefix), not a hardcoded per-component list: the linked-versions plugin (grouping
# engine with its dependents, release-please-config.json) puts mcp/engine/miner on ONE shared
# `--groups--<groupName>` branch instead of three separate `--components--<name>` branches, so
# a hardcoded `for component in mcp engine miner ui-kit` loop checking only the old
# `--components--` naming would silently never find/sync that branch at all (confirmed live:
# PR #7127's engine-and-dependents group branch left expected-engine.version stale, failing
# engine-parity:drift-check and two engine-version-display tests). Similarly, the
# expected-engine.version sync itself is now driven by comparing the checked-out branch's own
# committed pin against its own package.json (not an `if [ component = engine ]` check), so it
# fires correctly whether engine's bump landed via a solo branch or a shared group branch.
- name: Sync package-lock.json and engine-version pin on any release branch
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
trusted_sync_script="${RUNNER_TEMP}/sync-release-lockfile-versions.mjs"
cp scripts/sync-release-lockfile-versions.mjs "$trusted_sync_script"
git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git"
gh auth setup-git
branches="$(git ls-remote --heads origin 'release-please--branches--main--*' | awk '{print $2}' | sed 's#refs/heads/##')"
if [ -z "$branches" ]; then
echo "No release-please branches found, skipping."
fi
for branch in $branches; do
git fetch origin "$branch"
git checkout -B "sync-check-$(printf '%s' "$branch" | tr '/' '-')" "origin/$branch"
node "$trusted_sync_script" packages/loopover-mcp packages/loopover-engine packages/loopover-miner packages/loopover-ui-kit
if git diff --quiet package-lock.json; then
echo "package-lock.json already in sync on $branch."
else
git add package-lock.json
git commit -m "chore(release): sync package-lock.json"
git push origin "HEAD:$branch"
fi
engine_version="$(node -p "require('./packages/loopover-engine/package.json').version")"
printf '%s\n' "$engine_version" > packages/loopover-miner/expected-engine.version
if git diff --quiet packages/loopover-miner/expected-engine.version; then
echo "expected-engine.version already in sync on $branch."
else
git add packages/loopover-miner/expected-engine.version
git commit -m "chore(release): sync miner engine-version pin"
git push origin "HEAD:$branch"
fi
done
# release-please creates the component tag + GitHub Release with GITHUB_TOKEN, which (by
# GitHub's recursion-prevention rule) does NOT fire push/tag-based workflows. workflow_dispatch
# IS exempt from that rule, so dispatch the OIDC publish workflow explicitly, telling it
# release-please already created the tag/release (released_by_release_please=true skips both
# workflows' own tag-creation and GitHub-release steps). Without this, merging a Release PR
# would silently never publish.
#
# Dispatched against the release tag itself, NOT main: main can move between this step and
# the dispatched run actually starting (anything else merging in that window), and the
# publish workflow's own safety check hard-fails if the commit it's building doesn't match
# the tag release-please just created (confirmed live: engine-v0.2.0's first dry run hit
# exactly this race when an unrelated PR merged seconds apart). A tag doesn't move, so
# dispatching against it makes the publish deterministic regardless of main's activity.
- name: Dispatch MCP publish
if: ${{ steps.release.outputs['packages/loopover-mcp--release_created'] == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run publish-mcp.yml --ref "${{ steps.release.outputs['packages/loopover-mcp--tag_name'] }}" -f released_by_release_please=true
- name: Dispatch Engine publish
if: ${{ steps.release.outputs['packages/loopover-engine--release_created'] == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run publish-engine.yml --ref "${{ steps.release.outputs['packages/loopover-engine--tag_name'] }}" -f released_by_release_please=true
- name: Dispatch Miner publish
if: ${{ steps.release.outputs['packages/loopover-miner--release_created'] == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run publish-miner.yml --ref "${{ steps.release.outputs['packages/loopover-miner--tag_name'] }}" -f released_by_release_please=true
- name: Dispatch UI Kit publish
if: ${{ steps.release.outputs['packages/loopover-ui-kit--release_created'] == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run publish-ui-kit.yml --ref "${{ steps.release.outputs['packages/loopover-ui-kit--tag_name'] }}" -f released_by_release_please=true
# Self-heal for a known, reproducible, upstream googleapis/release-please limitation
# (googleapis/release-please#1946, #1444, #1406 -- all the same "There are untagged, merged release
# PRs outstanding - aborting" message, unresolved upstream): createReleases() (which actually tags a
# merged Release PR) sometimes finds nothing to do, and createPullRequests() then aborts on every
# subsequent run without ever tagging the merged PR -- confirmed live across two separate
# linked-versions release cycles this session (#7127, #7133), both requiring a manual dispatch of
# each publish-*.yml workflow plus manually flipping the merged PR's `autorelease: pending` label to
# `autorelease: tagged` to unstick it. Traced as far as release-please's own source
# (manifest.ts/workspace.ts) allows without being able to modify the third-party action itself.
#
# Rather than depend on release-please's own `steps.release.outputs` above (unreliable exactly when
# this bug is in play, since createReleases() never actually tagged anything), this reconciles from
# the OTHER direction: compare each package's committed package.json version against what's
# ACTUALLY live on npm, and self-heal by dispatching that package's publish workflow directly
# whenever they disagree -- the same "bare manual dispatch" human-override path each publish-*.yml
# already documents, just triggered automatically instead of by a human noticing red CI on an
# unrelated PR days later. Runs on every push (cheap: a handful of `npm view` calls, no-op when
# everything's already in sync), independent of whether the release-please job above succeeded,
# aborted, or hit a transient error.
#
# Engine publishes first and is awaited before mcp/miner: they carry a real runtime dependency on
# it, and their own isolated pack/smoke-test step resolves @loopover/engine from the real npm
# registry (no local workspace symlink available in that isolated temp dir), so it fails with
# ETARGET if engine hasn't actually finished publishing yet (confirmed live: the first attempt at
# this exact reconciliation, dispatched in parallel, hit that exact race).
reconcile-stale-releases:
runs-on: ubuntu-latest
needs: release-please
if: ${{ !cancelled() }}
environment: release
timeout-minutes: 25
permissions:
contents: read
actions: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24.18.0
- name: Publish any package whose committed version isn't live on npm yet
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
run: |
set -euo pipefail
needs_publish() {
local dir="$1" pkg="$2"
local local_version published_version
local_version="$(node -p "require('./$dir/package.json').version")"
published_version="$(npm view "$pkg" version 2>/dev/null || echo "")"
if [ "$local_version" != "$published_version" ]; then
echo "$pkg: committed $local_version, npm has ${published_version:-<none>}."
return 0
fi
echo "$pkg: already in sync at $local_version."
return 1
}
dispatch_and_wait() {
local workflow="$1"
gh workflow run "$workflow" --repo "$GITHUB_REPOSITORY"
sleep 8
local run_id
run_id="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 1 --json databaseId --jq '.[0].databaseId')"
echo "Dispatched $workflow as run $run_id, waiting for it to complete..."
while [ "$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json status --jq '.status')" != "completed" ]; do
sleep 15
done
local conclusion
conclusion="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion')"
echo "$workflow (run $run_id) concluded: $conclusion"
[ "$conclusion" = "success" ]
}
engine_ok=true
if needs_publish packages/loopover-engine @loopover/engine; then
dispatch_and_wait publish-engine.yml || engine_ok=false
fi
if [ "$engine_ok" = "true" ]; then
if needs_publish packages/loopover-mcp @loopover/mcp; then
dispatch_and_wait publish-mcp.yml || echo "::warning::publish-mcp.yml did not succeed -- left for manual follow-up."
fi
if needs_publish packages/loopover-miner @loopover/miner; then
dispatch_and_wait publish-miner.yml || echo "::warning::publish-miner.yml did not succeed -- left for manual follow-up."
fi
else
echo "::warning::Skipping mcp/miner reconciliation -- publish-engine.yml did not succeed, and they depend on it."
fi
if needs_publish packages/loopover-ui-kit @loopover/ui-kit; then
dispatch_and_wait publish-ui-kit.yml || echo "::warning::publish-ui-kit.yml did not succeed -- left for manual follow-up."
fi
# Once every committed version is confirmed live on npm (the loop above didn't need to run, or
# completed successfully for everything it dispatched), any merged Release PR still carrying
# release-please's own `autorelease: pending` label is exactly the stuck state this job exists
# to fix -- flip it so release-please's NEXT run stops aborting on it.
- name: Un-stick release-please's own tracking on any merged-but-still-pending release PR
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
run: |
set -euo pipefail
for dir_pkg in "packages/loopover-engine:@loopover/engine" "packages/loopover-mcp:@loopover/mcp" "packages/loopover-miner:@loopover/miner" "packages/loopover-ui-kit:@loopover/ui-kit"; do
dir="${dir_pkg%%:*}"
pkg="${dir_pkg##*:}"
local_version="$(node -p "require('./$dir/package.json').version")"
published_version="$(npm view "$pkg" version 2>/dev/null || echo "")"
if [ "$local_version" != "$published_version" ]; then
echo "::warning::$pkg still not live on npm (committed $local_version, npm has ${published_version:-<none>}) -- leaving autorelease labels alone."
exit 0
fi
done
numbers="$(gh pr list --repo "$GITHUB_REPOSITORY" --state merged --label "autorelease: pending" --json number --jq '.[].number')"
for number in $numbers; do
echo "Flipping autorelease label on merged PR #$number -- its packages are confirmed live on npm."
gh pr edit "$number" --repo "$GITHUB_REPOSITORY" --remove-label "autorelease: pending" --add-label "autorelease: tagged"
done