Release All Packages #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release All Packages | |
| # Each package carries its OWN version (see its CHANGELOG + metadata file). | |
| # `version` below is ONLY the umbrella GitHub release tag -- it is never written | |
| # into any package. Each package publishes the version already committed in its | |
| # metadata file. | |
| # | |
| # Preflight rules, per package: | |
| # metadata != CHANGELOG head -> ERROR (metadata was not bumped for the release) | |
| # metadata already published -> SKIP (nothing new to ship; not an error) | |
| # otherwise -> PUBLISH | |
| # | |
| # Deprecated packages are excluded entirely: isonantic (Python), isonantic-ts, | |
| # isonantic-go -- all merged into their parser packages. Publish a final | |
| # deprecation release manually via the per-ecosystem workflows if ever needed. | |
| # | |
| # Not covered here (released separately): ison-go / isonantic-go (git tags), | |
| # ison-cpp / isonantic-cpp (header-only, no registry), ison-cs (NuGet), | |
| # ison-cli (PyPI, never published), ison-vscode (VS Code Marketplace). | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Umbrella release tag (e.g. 1.0.4) -- NOT the per-package version' | |
| required: true | |
| type: string | |
| dry_run: | |
| description: 'Dry run (no actual publish)' | |
| required: false | |
| default: true | |
| type: boolean | |
| jobs: | |
| # =========================================== | |
| # PHASE 1: Preflight, Validate and Test | |
| # =========================================== | |
| preflight: | |
| name: Preflight Version Check | |
| runs-on: ubuntu-latest | |
| outputs: | |
| ison_js: ${{ steps.check.outputs.ison_js }} | |
| ison_ts: ${{ steps.check.outputs.ison_ts }} | |
| ison_py: ${{ steps.check.outputs.ison_py }} | |
| ison_rs: ${{ steps.check.outputs.ison_rs }} | |
| isonantic_rs: ${{ steps.check.outputs.isonantic_rs }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| # `version` is free text, so it goes through env rather than being | |
| # interpolated into the script. | |
| - name: Validate umbrella tag format | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then | |
| echo "::error::Invalid version format: $VERSION (expected X.Y.Z or X.Y.Z-prerelease)" | |
| exit 1 | |
| fi | |
| - name: Verify metadata matches CHANGELOG and determine what to publish | |
| id: check | |
| run: | | |
| set -uo pipefail | |
| FAIL=0 | |
| { | |
| echo "## Preflight: per-package versions" | |
| echo "" | |
| echo "| Package | Registry name | Metadata | CHANGELOG | Published | Action |" | |
| echo "|---|---|---|---|---|---|" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| check() { | |
| local key="$1" dir="$2" registry="$3" name="$4" meta="$5" | |
| local head published action | |
| if [ ! -f "$dir/CHANGELOG.md" ]; then | |
| echo "::error::$dir has no CHANGELOG.md" | |
| FAIL=1 | |
| return | |
| fi | |
| head="$(grep -m1 '^## \[' "$dir/CHANGELOG.md" | sed -E 's/^## \[([^]]+)\].*/\1/')" | |
| published=no | |
| case "$registry" in | |
| npm) | |
| npm view "$name@$meta" version >/dev/null 2>&1 && published=yes ;; | |
| pypi) | |
| [ "$(curl -sS -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/$name/$meta/json")" = "200" ] && published=yes ;; | |
| crates) | |
| curl -sS -H 'User-Agent: ison-release-ci' "https://crates.io/api/v1/crates/$name/$meta" \ | |
| | grep -q "\"num\":\"$meta\"" && published=yes ;; | |
| esac | |
| if [ "$meta" != "$head" ]; then | |
| action='**ERROR**' | |
| echo "::error::$dir metadata version ($meta) != CHANGELOG head ($head). Bump $dir metadata to $head before releasing." | |
| FAIL=1 | |
| elif [ "$published" = "yes" ]; then | |
| action='skip (already published)' | |
| echo "$key=skip" >> "$GITHUB_OUTPUT" | |
| else | |
| action='publish' | |
| echo "$key=publish" >> "$GITHUB_OUTPUT" | |
| fi | |
| printf '| %s | %s | %s | %s | %s | %s |\n' \ | |
| "$dir" "$name" "$meta" "$head" "$published" "$action" >> "$GITHUB_STEP_SUMMARY" | |
| } | |
| npm_version() { node -p "require('./$1/package.json').version"; } | |
| toml_version() { grep -m1 '^version' "$1" | sed -E 's/.*"([^"]+)".*/\1/'; } | |
| check ison_js ison-js npm ison-parser "$(npm_version ison-js)" | |
| check ison_ts ison-ts npm ison-ts "$(npm_version ison-ts)" | |
| check ison_py ison-py pypi ison-py "$(toml_version ison-py/pyproject.toml)" | |
| check ison_rs ison-rust crates ison-rs "$(toml_version ison-rust/Cargo.toml)" | |
| check isonantic_rs isonantic-rust crates isonantic-rs "$(toml_version isonantic-rust/Cargo.toml)" | |
| if [ "$FAIL" -ne 0 ]; then | |
| { | |
| echo "" | |
| echo "Preflight failed. Every package's metadata version must equal its CHANGELOG head." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| # NOTE: there is no root package.json -- this repo is not an npm workspace. | |
| # Every npm step must run inside its own package directory. | |
| test-npm: | |
| name: Test NPM Packages | |
| runs-on: ubuntu-latest | |
| needs: preflight | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Test ison-js | |
| working-directory: ison-js | |
| run: | | |
| npm ci | |
| mkdir -p dist | |
| npm run build | |
| npm test | |
| - name: Test ison-ts | |
| working-directory: ison-ts | |
| run: | | |
| npm ci | |
| npm run build | |
| npm test | |
| test-python: | |
| name: Test Python Packages | |
| runs-on: ubuntu-latest | |
| needs: preflight | |
| strategy: | |
| matrix: | |
| python-version: ['3.9', '3.11', '3.12'] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Test ison-py | |
| working-directory: ison-py | |
| run: | | |
| pip install -e ".[dev]" | |
| pytest tests/ -v | |
| # NOTE: there is no root Cargo.toml -- this repo is not a cargo workspace. | |
| # Every cargo step must run inside its own crate directory. | |
| test-rust: | |
| name: Test Rust Packages | |
| runs-on: ubuntu-latest | |
| needs: preflight | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Test ison-rust | |
| working-directory: ison-rust | |
| run: cargo test --all-features | |
| - name: Test isonantic-rust | |
| working-directory: isonantic-rust | |
| run: cargo test --all-features | |
| # =========================================== | |
| # PHASE 2: Publish Base Packages | |
| # =========================================== | |
| publish-npm-base: | |
| name: Publish NPM (ison-parser, ison-ts) | |
| runs-on: ubuntu-latest | |
| needs: [preflight, test-npm, test-python, test-rust] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| registry-url: 'https://registry.npmjs.org' | |
| - name: Publish ison-js | |
| if: ${{ needs.preflight.outputs.ison_js == 'publish' }} | |
| working-directory: ison-js | |
| run: | | |
| npm ci | |
| mkdir -p dist | |
| npm run build | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| npm publish --dry-run | |
| else | |
| npm publish --access public | |
| fi | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| - name: Publish ison-ts | |
| if: ${{ needs.preflight.outputs.ison_ts == 'publish' }} | |
| working-directory: ison-ts | |
| run: | | |
| npm ci | |
| npm run build | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| npm publish --dry-run | |
| else | |
| npm publish --access public | |
| fi | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| publish-python-base: | |
| name: Publish PyPI (ison-py) | |
| runs-on: ubuntu-latest | |
| needs: [preflight, test-npm, test-python, test-rust] | |
| if: ${{ needs.preflight.outputs.ison_py == 'publish' }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - run: pip install build twine | |
| - name: Build and publish ison-py | |
| working-directory: ison-py | |
| run: | | |
| python -m build | |
| twine check dist/* | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| ls -la dist/ | |
| else | |
| twine upload dist/* | |
| fi | |
| env: | |
| TWINE_USERNAME: __token__ | |
| TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} | |
| publish-rust-base: | |
| name: Publish Crates.io (ison-rs) | |
| runs-on: ubuntu-latest | |
| needs: [preflight, test-npm, test-python, test-rust] | |
| if: ${{ needs.preflight.outputs.ison_rs == 'publish' }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Publish ison-rs | |
| working-directory: ison-rust | |
| run: | | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| cargo publish --dry-run | |
| else | |
| cargo publish | |
| fi | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} | |
| # =========================================== | |
| # PHASE 3: Publish Dependent Packages | |
| # =========================================== | |
| publish-rust-dependent: | |
| name: Publish Crates.io Dependent (isonantic-rs) | |
| runs-on: ubuntu-latest | |
| needs: [preflight, publish-rust-base] | |
| if: ${{ always() && !failure() && !cancelled() && needs.preflight.outputs.isonantic_rs == 'publish' }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Wait for crates.io propagation | |
| if: ${{ inputs.dry_run != true && needs.preflight.outputs.ison_rs == 'publish' }} | |
| run: sleep 60 | |
| - name: Publish isonantic-rs | |
| working-directory: isonantic-rust | |
| run: | | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| cargo publish --dry-run | |
| else | |
| cargo publish | |
| fi | |
| env: | |
| CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} | |
| # =========================================== | |
| # PHASE 4: Create Release | |
| # =========================================== | |
| create-release: | |
| name: Create GitHub Release | |
| runs-on: ubuntu-latest | |
| needs: [publish-npm-base, publish-python-base, publish-rust-base, publish-rust-dependent] | |
| if: ${{ always() && !failure() && !cancelled() && inputs.dry_run != true }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Collect published versions | |
| id: versions | |
| run: | | |
| { | |
| echo "ison_js=$(node -p "require('./ison-js/package.json').version")" | |
| echo "ison_ts=$(node -p "require('./ison-ts/package.json').version")" | |
| echo "ison_py=$(grep -m1 '^version' ison-py/pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/')" | |
| echo "ison_rs=$(grep -m1 '^version' ison-rust/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" | |
| echo "isonantic_rs=$(grep -m1 '^version' isonantic-rust/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Create Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ inputs.version }} | |
| name: Release v${{ inputs.version }} | |
| body: | | |
| ## ISON v${{ inputs.version }} | |
| Each package is versioned independently. Current versions: | |
| **NPM (JavaScript/TypeScript)** | |
| - `ison-parser@${{ steps.versions.outputs.ison_js }}` - [npm](https://www.npmjs.com/package/ison-parser) | |
| - `ison-ts@${{ steps.versions.outputs.ison_ts }}` - [npm](https://www.npmjs.com/package/ison-ts) | |
| **PyPI (Python)** | |
| - `ison-py==${{ steps.versions.outputs.ison_py }}` - [PyPI](https://pypi.org/project/ison-py/) | |
| **Crates.io (Rust)** | |
| - `ison-rs@${{ steps.versions.outputs.ison_rs }}` - [crates.io](https://crates.io/crates/ison-rs) | |
| - `isonantic-rs@${{ steps.versions.outputs.isonantic_rs }}` - [crates.io](https://crates.io/crates/isonantic-rs) | |
| > **Deprecated:** `isonantic` (Python), `isonantic-ts`, and `isonantic-go` have been | |
| > merged into `ison-py`, `ison-ts`, and `ison-go` respectively. Use | |
| > `ison_parser.validation`, `ison-ts/validation`, and `ison-go/validation`. | |
| ### Installation | |
| ```bash | |
| # JavaScript/TypeScript | |
| npm install ison-parser@${{ steps.versions.outputs.ison_js }} | |
| npm install ison-ts@${{ steps.versions.outputs.ison_ts }} | |
| # Python | |
| pip install ison-py==${{ steps.versions.outputs.ison_py }} | |
| # Rust | |
| cargo add ison-rs@${{ steps.versions.outputs.ison_rs }} | |
| cargo add isonantic-rs@${{ steps.versions.outputs.isonantic_rs }} | |
| ``` | |
| draft: false | |
| prerelease: ${{ contains(inputs.version, '-') }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| summary: | |
| name: Release Summary | |
| runs-on: ubuntu-latest | |
| needs: [preflight, publish-npm-base, publish-python-base, publish-rust-base, publish-rust-dependent] | |
| if: always() | |
| steps: | |
| - name: Summary | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| { | |
| echo "## Release Summary" | |
| echo "" | |
| echo "**Umbrella tag:** v$VERSION" | |
| echo "**Dry run:** ${{ inputs.dry_run }}" | |
| echo "" | |
| echo "| Package | Preflight action |" | |
| echo "|---|---|" | |
| echo "| ison-parser | ${{ needs.preflight.outputs.ison_js }} |" | |
| echo "| ison-ts | ${{ needs.preflight.outputs.ison_ts }} |" | |
| echo "| ison-py | ${{ needs.preflight.outputs.ison_py }} |" | |
| echo "| ison-rs | ${{ needs.preflight.outputs.ison_rs }} |" | |
| echo "| isonantic-rs | ${{ needs.preflight.outputs.isonantic_rs }} |" | |
| echo "" | |
| if [ "${{ inputs.dry_run }}" = "true" ]; then | |
| echo "Dry run -- no packages were published." | |
| else | |
| echo "Go / C++ / C# / VS Code packages are released separately." | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" |