ci(release): announce releases in repo and org discussions (#18) #13
Workflow file for this run
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 (binaries + Python wheels) | |
| # Builds the self-contained cants binary for every supported | |
| # platform and publishes: | |
| # 1. the raw binaries as GitHub Release assets (for analysis_backend_path / | |
| # $CODEANALYZER_TS_BIN / download-on-first-use use cases), and | |
| # 2. platform-tagged Python wheels to PyPI as `codeanalyzer-typescript`. | |
| # | |
| # Bun cross-compiles all targets from one host, so a single Linux job suffices. | |
| on: | |
| push: | |
| tags: | |
| - "v*.*.*" | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: write # create GitHub Release + delete tag on failure | |
| id-token: write # PyPI Trusted Publishing (OIDC) -- no API token needed | |
| jobs: | |
| release: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check out code | |
| uses: actions/checkout@v4 | |
| # Derive the release version from the tag (vX.Y.Z -> X.Y.Z) and require it | |
| # to match package.json. This keeps the PyPI wheel version, the GitHub | |
| # Release tag, and the npm version in lockstep -- a mismatch fails fast | |
| # rather than silently publishing the wrong version to PyPI. | |
| - name: Determine and verify version | |
| id: ver | |
| run: | | |
| pkgjson="$(node -p "require('./package.json').version")" | |
| if [[ "${GITHUB_REF}" == refs/tags/* ]]; then | |
| version="${GITHUB_REF#refs/tags/v}" | |
| if [[ "$version" != "$pkgjson" ]]; then | |
| echo "::error::Tag version ($version) != package.json version ($pkgjson). Bump package.json or fix the tag." | |
| exit 1 | |
| fi | |
| else | |
| version="$pkgjson" # workflow_dispatch: no tag, fall back to package.json | |
| fi | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| echo ">>> Releasing version $version" | |
| - name: Set up Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Install analyzer dependencies | |
| run: bun install | |
| # Keep the generated docs in lockstep with the binary being released: regenerate the | |
| # README `cants --help` block and the Neo4j schema.json from source, and commit them back to | |
| # main. Releases are cut from main HEAD, so this fast-forwards; it's best-effort if main moved. | |
| - name: Sync generated docs before release (README --help + Neo4j schema) | |
| if: startsWith(github.ref, 'refs/tags/') | |
| run: | | |
| bun run gen:readme | |
| bun run gen:schema | |
| if git diff --quiet README.md schema.neo4j.json; then | |
| echo "Generated docs already current." | |
| else | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add README.md schema.neo4j.json | |
| git commit -m "docs: sync README --help and Neo4j schema for v${{ steps.ver.outputs.version }}" | |
| git push origin HEAD:main || echo "::warning::could not push doc sync to main (diverged?)" | |
| fi | |
| # ----- test gate: a broken build must not produce a release ----- | |
| - name: Typecheck and test | |
| id: test | |
| continue-on-error: true | |
| run: | | |
| bun run typecheck | |
| # Tolerate a repo with no test files (bun test exits 1 when it finds none), | |
| # but still fail the gate on real test failures when tests DO exist. | |
| if find . -path ./node_modules -prune -o \ | |
| \( -name '*.test.ts' -o -name '*.test.tsx' -o -name '*.test.js' -o -name '*.test.jsx' \ | |
| -o -name '*.spec.ts' -o -name '*.spec.tsx' -o -name '*.spec.js' -o -name '*.spec.jsx' \) \ | |
| -print | grep -q .; then | |
| bun test | |
| else | |
| echo "No test files found — skipping bun test." | |
| fi | |
| - name: Delete tag on failure | |
| if: steps.test.conclusion == 'failure' && startsWith(github.ref, 'refs/tags/') | |
| run: | | |
| echo "Tests failed. Deleting tag ${GITHUB_REF#refs/tags/}..." | |
| git config --global user.name "github-actions[bot]" | |
| git config --global user.email "github-actions[bot]@users.noreply.github.com" | |
| git push --delete origin "${GITHUB_REF#refs/tags/}" | |
| exit 1 | |
| - name: Fail if tests failed (non-tag runs) | |
| if: steps.test.conclusion == 'failure' | |
| run: exit 1 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install Python build tooling | |
| # hatchling is the build backend; build_wheels.sh runs `python -m build | |
| # --no-isolation`, so it must be present in this env (no auto-install). | |
| run: python -m pip install --upgrade build wheel hatchling | |
| - name: Build platform wheels (cross-compiles every target via Bun) | |
| working-directory: packaging/python | |
| env: | |
| PKG_VERSION: ${{ steps.ver.outputs.version }} | |
| run: ./build_wheels.sh | |
| - name: Extract raw binaries from wheels (for GitHub Release) | |
| working-directory: packaging/python | |
| run: | | |
| mkdir -p ../../release-bins | |
| for whl in dist/*.whl; do | |
| plat="$(basename "$whl" .whl | sed 's/.*-py3-none-//')" | |
| tmp="$(mktemp -d)" | |
| python -m zipfile -e "$whl" "$tmp" | |
| # _bin/ now also holds schema.json, so select the binary by name (cants / cants.exe). | |
| bin="$(find "$tmp/codeanalyzer_typescript/_bin" -type f -name 'cants*')" | |
| ext=""; [[ "$bin" == *.exe ]] && ext=".exe" | |
| cp "$bin" "../../release-bins/cants-${plat}${ext}" | |
| done | |
| ls -lh ../../release-bins | |
| # The Neo4j schema contract is platform-independent and version-locked to this build. | |
| # Publish it as a Release asset alongside the raw binaries (it also ships inside each wheel). | |
| # generate_formula.sh reads only the named cants-<platform> assets, so this is harmless to it. | |
| - name: Generate Neo4j schema contract (release asset) | |
| run: bun run src/index.ts --emit schema > release-bins/schema.json | |
| # Publish the cargo-dist-style install script so users can: | |
| # curl --proto '=https' --tlsv1.2 -LsSf .../releases/latest/download/cants-installer.sh | sh | |
| - name: Stage the install script (release asset) | |
| run: cp packaging/install/cants-installer.sh release-bins/cants-installer.sh | |
| - name: Build changelog (auto-generated from commits/PRs) | |
| id: changelog | |
| if: startsWith(github.ref, 'refs/tags/') | |
| uses: mikepenz/release-changelog-builder-action@v5 | |
| with: | |
| configuration: ".github/configuration.json" | |
| failOnError: "false" | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # cargo-dist-style notes: install one-liners + a download table, then the auto-generated | |
| # changelog from the step above (kept). Indented code blocks avoid backticks in the heredoc. | |
| - name: Compose release notes | |
| id: notes | |
| if: startsWith(github.ref, 'refs/tags/') | |
| env: | |
| VERSION: ${{ steps.ver.outputs.version }} | |
| CHANGELOG: ${{ steps.changelog.outputs.changelog }} | |
| run: | | |
| REPO="codellm-devkit/codeanalyzer-typescript" | |
| BASE="https://github.com/$REPO/releases/download/v$VERSION" | |
| cat > "$RUNNER_TEMP/RELEASE_BODY.md" <<EOF | |
| ## Install codeanalyzer-typescript v$VERSION | |
| Shell script (prebuilt binary; macOS and Linux): | |
| curl --proto '=https' --tlsv1.2 -LsSf https://github.com/$REPO/releases/latest/download/cants-installer.sh | sh | |
| Homebrew: | |
| brew install codellm-devkit/homebrew-tap/codeanalyzer-typescript | |
| PyPI: | |
| pip install codeanalyzer-typescript==$VERSION | |
| ## Download | |
| | File | Platform | | |
| | --- | --- | | |
| | [cants-macosx_11_0_arm64]($BASE/cants-macosx_11_0_arm64) | Apple Silicon macOS | | |
| | [cants-macosx_10_12_x86_64]($BASE/cants-macosx_10_12_x86_64) | Intel macOS | | |
| | [cants-manylinux2014_x86_64]($BASE/cants-manylinux2014_x86_64) | x86-64 Linux | | |
| | [cants-manylinux2014_aarch64]($BASE/cants-manylinux2014_aarch64) | ARM64 Linux | | |
| | [cants-win_amd64.exe]($BASE/cants-win_amd64.exe) | x64 Windows | | |
| | [cants-installer.sh]($BASE/cants-installer.sh) | Shell installer | | |
| | [schema.json]($BASE/schema.json) | Neo4j schema contract | | |
| ## Changelog | |
| $CHANGELOG | |
| EOF | |
| echo "----- composed release body -----"; cat "$RUNNER_TEMP/RELEASE_BODY.md" | |
| - name: Publish GitHub Release (raw binaries) | |
| uses: softprops/action-gh-release@v2 | |
| if: startsWith(github.ref, 'refs/tags/') | |
| with: | |
| files: release-bins/* | |
| body_path: ${{ runner.temp }}/RELEASE_BODY.md | |
| # Auto-open a repo-level Discussion linked to this release, seeded with | |
| # the same notes. Requires Discussions enabled and this category to exist. | |
| discussion_category_name: Announcements | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Mirror the release announcement into the ORG-level discussions, which are | |
| # backed by codellm-devkit/.github. GITHUB_TOKEN can't write cross-repo, so | |
| # this uses a PAT/App token (ORG_DISCUSSIONS_TOKEN) with discussions:write | |
| # on that repo, and posts via the createDiscussion GraphQL mutation. Reuses | |
| # the already-composed RELEASE_BODY.md so org and repo posts stay identical. | |
| - name: Announce in org-level discussions (codellm-devkit/.github) | |
| if: startsWith(github.ref, 'refs/tags/') | |
| continue-on-error: true # a failed org post must not fail an otherwise-good release | |
| env: | |
| GH_TOKEN: ${{ secrets.ORG_DISCUSSIONS_TOKEN }} | |
| VERSION: ${{ steps.ver.outputs.version }} | |
| run: | | |
| set -uo pipefail | |
| OWNER="codellm-devkit"; REPO=".github"; CATEGORY="Announcements" | |
| # The mutation needs GraphQL node IDs, not names — resolve them first. | |
| RESP=$(gh api graphql \ | |
| -f query='query($o:String!,$r:String!){repository(owner:$o,name:$r){id discussionCategories(first:25){nodes{id name}}}}' \ | |
| -f o="$OWNER" -f r="$REPO") \ | |
| || { echo "::warning::org discussion lookup failed — skipping org announcement."; exit 0; } | |
| REPO_ID=$(echo "$RESP" | jq -r '.data.repository.id') | |
| CAT_ID=$(echo "$RESP" | jq -r --arg c "$CATEGORY" '.data.repository.discussionCategories.nodes[]|select(.name==$c)|.id') | |
| if [[ -z "$REPO_ID" || "$REPO_ID" == "null" || -z "$CAT_ID" ]]; then | |
| echo "::warning::could not resolve $OWNER/$REPO discussion category '$CATEGORY' — skipping org announcement." | |
| exit 0 | |
| fi | |
| gh api graphql \ | |
| -f query='mutation($rid:ID!,$cid:ID!,$t:String!,$b:String!){createDiscussion(input:{repositoryId:$rid,categoryId:$cid,title:$t,body:$b}){discussion{url}}}' \ | |
| -f rid="$REPO_ID" -f cid="$CAT_ID" \ | |
| -f t="codeanalyzer-typescript v$VERSION" \ | |
| -f b="$(cat "$RUNNER_TEMP/RELEASE_BODY.md")" | |
| - name: Publish wheels to PyPI (Trusted Publishing / OIDC) | |
| if: startsWith(github.ref, 'refs/tags/') | |
| uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| packages-dir: packaging/python/dist | |
| # Make re-runs idempotent: a version already on PyPI is skipped rather | |
| # than failing the whole job with a 400 (PyPI rejects duplicate uploads). | |
| skip-existing: true | |
| # Hand the raw binaries to the separate `homebrew` job below. Passing them | |
| # as an artifact (rather than re-downloading the GitHub Release) keeps the | |
| # formula checksums byte-identical to what was just published. | |
| - name: Upload release binaries for the homebrew job | |
| if: startsWith(github.ref, 'refs/tags/') | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: release-bins | |
| path: release-bins/ | |
| # ----- Homebrew: regenerate the formula from the just-built binaries and push | |
| # it to the shared tap. Split into its own job (needs: release) so a tap-push | |
| # failure -- e.g. a missing HOMEBREW_TAP_TOKEN -- is isolated from the PyPI and | |
| # GitHub Release steps above, and can be re-run on its own without re-uploading | |
| # wheels. This is the non-Rust equivalent of what cargo-dist does for you. | |
| homebrew: | |
| needs: release | |
| if: startsWith(github.ref, 'refs/tags/') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check out code | |
| uses: actions/checkout@v4 | |
| - name: Derive version from tag | |
| id: ver | |
| run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" | |
| - name: Download release binaries | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: release-bins | |
| path: release-bins | |
| - name: Generate Homebrew formula | |
| env: | |
| REPO: ${{ github.repository }} | |
| VERSION: ${{ steps.ver.outputs.version }} | |
| run: | | |
| ./packaging/homebrew/generate_formula.sh release-bins > codeanalyzer-typescript.rb | |
| cat codeanalyzer-typescript.rb | |
| - name: Push formula to codellm-devkit/homebrew-tap | |
| env: | |
| TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} # PAT with write access to homebrew-tap | |
| VERSION: ${{ steps.ver.outputs.version }} | |
| run: | | |
| git clone "https://x-access-token:${TAP_TOKEN}@github.com/codellm-devkit/homebrew-tap.git" tap | |
| mkdir -p tap/Formula | |
| cp codeanalyzer-typescript.rb tap/Formula/codeanalyzer-typescript.rb | |
| cd tap | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add Formula/codeanalyzer-typescript.rb | |
| git commit -m "codeanalyzer-typescript ${VERSION}" || { echo "no formula change"; exit 0; } | |
| git push |