From dc04ae399961c903ef0459c1a871e8da5c1e063b Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Wed, 1 Jul 2026 16:17:36 +0200 Subject: [PATCH] ci: add orchestrated one-click release workflow Add a manual workflow_dispatch release that publishes every package whose pubspec version is not yet on pub.dev, in dependency order (solid_annotations then solid_generator), skipping already-published versions. scripts/release.sh reads each pubspec version (source of truth; no version bumping), skips versions already on pub.dev, creates a GitHub Release + tag (-v) to trigger publish.yml, and waits for pub.dev to serve the new version before releasing dependents. Supports a dry_run input. Requires a RELEASE_PAT secret: tags pushed by the default GITHUB_TOKEN do not trigger publish.yml, so a separate identity is needed. --- .github/workflows/release.yaml | 34 +++++++++ scripts/release.sh | 136 +++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100755 scripts/release.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..86cf891 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,34 @@ +name: Release to pub.dev (orchestrated) + +# One-click release: publishes every package whose pubspec version is not yet on +# pub.dev, in dependency order, waiting for each to become available before +# releasing its dependents. Already-published versions are skipped. +# +# Requires the `RELEASE_PAT` repo secret (a fine-grained PAT with Contents: write, +# or a GitHub App token). Tags pushed by the default GITHUB_TOKEN do NOT trigger +# the publish.yml workflow, so a separate identity is required. + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Log the planned releases without creating tags/releases" + type: boolean + default: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history + tags + + - name: Orchestrate pub.dev releases + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + DRY_RUN: ${{ inputs.dry_run }} + run: bash scripts/release.sh diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..419a1da --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# +# Orchestrates pub.dev publishing for the solid monorepo. +# +# For every publishable package, in dependency order, this script: +# 1. reads the `version:` from its pubspec.yaml, +# 2. skips it if that version is already on pub.dev (no-op for unchanged packages), +# 3. otherwise creates a GitHub Release + tag (`-v`), which triggers +# the `publish.yml` workflow for that package, +# 4. waits until pub.dev actually serves the new version before moving on to the +# packages that depend on it. +# +# It does NOT publish directly and it does NOT bump versions — version bumps and +# CHANGELOG edits stay manual. The pubspec `version:` is the source of truth. +# +# Requires: gh, curl, jq, git (all preinstalled on ubuntu-latest). +# Environment: +# GH_TOKEN a non-GITHUB_TOKEN identity (PAT / App token) so the tag push it +# creates triggers the downstream publish.yml workflow. +# GITHUB_SHA the commit to tag (auto-set by GitHub Actions). +# DRY_RUN "true" to log the plan without creating any tags/releases. +# +set -euo pipefail + +# Publishable packages in topological order. A package is only released after +# every in-repo dependency it has is already live on pub.dev. +# solid_annotations -> (no in-repo deps) +# solid_generator -> solid_annotations +PACKAGES=( + "solid_annotations:packages/solid_annotations" + "solid_generator:packages/solid_generator" +) + +DRY_RUN="${DRY_RUN:-false}" +PUB_API="https://pub.dev/api/packages" +POLL_ATTEMPTS=60 # 60 * 30s = up to 30 min, to absorb pub.dev propagation lag +POLL_INTERVAL=30 + +# Read the package version from its pubspec (first `version:` line, value only). +read_version() { + awk '/^version:/{print $2; exit}' "$1/pubspec.yaml" +} + +# True when ($2) is already published for package ($1). +# A cache-busting query + no-cache header avoid a stale CDN response masking a +# just-published version. Network/HTTP errors are treated as "not published". +is_published() { + local body + body="$(curl -fsS -H 'Cache-Control: no-cache' "${PUB_API}/$1?_=${RANDOM}" 2>/dev/null || true)" + [ -n "$body" ] || return 1 + printf '%s' "$body" | jq -e --arg v "$2" '.versions[]?.version | select(. == $v)' >/dev/null 2>&1 +} + +# True when the tag ($1) already exists on the remote. +tag_exists() { + [ -n "$(git ls-remote --tags origin "refs/tags/$1")" ] +} + +# Print the CHANGELOG section for ($2) from /CHANGELOG.md ($1): +# everything between the `## ` heading and the next `## ` heading. +# Uses a literal heading compare to avoid regex-escaping the version string. +changelog_notes() { + local file="$1/CHANGELOG.md" + [ -f "$file" ] || return 0 + awk -v h="## $2" ' + { line = $0; sub(/[[:space:]]+$/, "", line) } + line == h { f = 1; next } + /^## / { if (f) exit } + f { print } + ' "$file" +} + +# Block until ($2) of ($1) is live on pub.dev, or fail. +wait_for_publish() { + local name="$1" version="$2" i + for ((i = 1; i <= POLL_ATTEMPTS; i++)); do + if is_published "$name" "$version"; then + return 0 + fi + echo " …not on pub.dev yet (attempt ${i}/${POLL_ATTEMPTS}); sleeping ${POLL_INTERVAL}s" + sleep "$POLL_INTERVAL" + done + echo " ✗ timed out after $((POLL_ATTEMPTS * POLL_INTERVAL / 60)) min waiting for ${name} ${version} on pub.dev" >&2 + return 1 +} + +[ "$DRY_RUN" = "true" ] && echo "DRY RUN — no tags or releases will be created." + +for entry in "${PACKAGES[@]}"; do + name="${entry%%:*}" + dir="${entry#*:}" + version="$(read_version "$dir")" + + if [ -z "$version" ]; then + echo "✗ could not read version from ${dir}/pubspec.yaml" >&2 + exit 1 + fi + + tag="${name}-v${version}" + echo "── ${name} ${version} (tag ${tag})" + + if is_published "$name" "$version"; then + echo " ✓ already on pub.dev — skipping" + continue + fi + + if tag_exists "$tag"; then + echo " ✗ tag ${tag} exists but ${version} is not on pub.dev — a previous publish likely failed." + echo " Re-run the failed '${name}' publish workflow, or delete the tag and re-run this workflow." + if [ "$DRY_RUN" = "true" ]; then + echo " [dry-run] continuing" + continue + fi + exit 1 + fi + + if [ "$DRY_RUN" = "true" ]; then + echo " [dry-run] would create release + tag ${tag}, then wait for pub.dev" + continue + fi + + notes="$(changelog_notes "$dir" "$version")" + + echo " → creating GitHub release + tag ${tag}" + release_args=("$tag" --title "$tag" --notes "${notes:-Release $tag}" --target "${GITHUB_SHA}") + case "$version" in + *-*) release_args+=(--prerelease) ;; # e.g. 3.0.0-dev.1 + esac + gh release create "${release_args[@]}" + + echo " → waiting for ${name} ${version} to appear on pub.dev" + wait_for_publish "$name" "$version" + echo " ✓ ${name} ${version} is live on pub.dev" +done + +echo "Done."