Release #52
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 | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| target: | |
| description: 'What to publish' | |
| type: choice | |
| options: | |
| - extension | |
| - mcp-server | |
| - lsp-server | |
| - both | |
| default: extension | |
| bump: | |
| description: 'Version bump type' | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| default: patch | |
| dry_run: | |
| description: 'Dry run — build & test only, skip publish/commit/tag' | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| id-token: write | |
| jobs: | |
| release: | |
| runs-on: ubuntu-latest | |
| steps: | |
| # ── Setup ────────────────────────────────────────────────── | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Configure git | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| - uses: pnpm/action-setup@v4 | |
| with: | |
| version: 9 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| cache: 'pnpm' | |
| registry-url: 'https://registry.npmjs.org' | |
| - run: pnpm install --frozen-lockfile | |
| # Exact pins — these tools run with VSCE_PAT/OVSX_PAT/NODE_AUTH_TOKEN in | |
| # scope, so a compromised latest release must not be auto-pulled here. | |
| - run: pnpm install -wD @vscode/vsce@3.9.2 ovsx@1.0.1 | |
| # Re-derive every native-binary digest from the registry and diff it | |
| # against the committed pin file, BEFORE anything is packaged. | |
| # | |
| # What this uniquely catches: a pin file that is internally consistent — | |
| # correct version, correct integrity — but carries WRONG DIGESTS. The two | |
| # neighbouring failure modes are already covered offline and would never | |
| # reach this step: a stale or missing pin fails closed in `binaryPin()` | |
| # during vendoring, and a re-published tarball fails the lockfile SHA-512 | |
| # on download. A poisoned pin file is the residual trust root, and it is | |
| # the one thing only a fresh re-derivation can expose. | |
| # | |
| # Placed here deliberately: the first publish is far below and the version | |
| # bump/commit/tag lower still, so a failure here ships nothing and tags | |
| # nothing. Kept OUT of `pnpm test` — it needs the network and pulls ~150 MB. | |
| # | |
| # THIS STEP IS THE GATE. `.github/workflows/verify-binary-pins.yml` runs | |
| # the same check on pull requests, but it is path-filtered and therefore | |
| # SKIPPED on most PRs — and a skipped run is not a verification. That | |
| # workflow is early warning for the contributor; this step is the safety | |
| # boundary. Do not remove it on the grounds that the PR job covers it. | |
| - name: Verify native-binary digest pins | |
| if: inputs.target == 'extension' || inputs.target == 'both' | |
| run: pnpm check:binary-digests | |
| # ── Compute versions ─────────────────────────────────────── | |
| # For each target, we check both package.json AND the registry. | |
| # We bump from whichever is higher, so we never collide with | |
| # an already-published version. | |
| - name: Bump extension version | |
| id: ext_version | |
| if: inputs.target == 'extension' || inputs.target == 'both' | |
| run: | | |
| PKG="packages/extension/package.json" | |
| LOCAL=$(node -p "require('./$PKG').version") | |
| # Query VS Code Marketplace for the latest published version | |
| PUBLISHED=$(pnpm exec vsce show Nskha.airtable-formula --json 2>/dev/null \ | |
| | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{ | |
| try{console.log(JSON.parse(d).versions[0].version)} | |
| catch{console.log('0.0.0')} | |
| })" || echo "0.0.0") | |
| # The Marketplace reply is external input that gets interpolated into | |
| # node -e below — accept strict semver only. | |
| if ! [[ "$PUBLISHED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "::warning::Marketplace returned non-semver version '${PUBLISHED}' — ignoring" | |
| PUBLISHED="0.0.0" | |
| fi | |
| echo "Local: ${LOCAL}, Marketplace: ${PUBLISHED}" | |
| # Pick the higher version as the base for bumping | |
| BASE=$(node -e " | |
| const a = '${LOCAL}'.split('.').map(Number); | |
| const b = '${PUBLISHED}'.split('.').map(Number); | |
| const cmp = a[0]-b[0] || a[1]-b[1] || a[2]-b[2]; | |
| console.log(cmp >= 0 ? '${LOCAL}' : '${PUBLISHED}'); | |
| ") | |
| NEXT=$(node -e " | |
| const [major, minor, patch] = '${BASE}'.split('.').map(Number); | |
| const bump = '${{ inputs.bump }}'; | |
| if (bump === 'major') console.log((major+1)+'.0.0'); | |
| else if (bump === 'minor') console.log(major+'.'+(minor+1)+'.0'); | |
| else console.log(major+'.'+minor+'.'+(patch+1)); | |
| ") | |
| echo "current=${BASE}" >> $GITHUB_OUTPUT | |
| echo "next=${NEXT}" >> $GITHUB_OUTPUT | |
| # Write new version to package.json | |
| node -e " | |
| const fs = require('fs'); | |
| const p = '$PKG'; | |
| const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); | |
| pkg.version = '${NEXT}'; | |
| fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); | |
| " | |
| echo "Extension: ${BASE} → ${NEXT}" | |
| env: | |
| VSCE_PAT: ${{ secrets.VSCE_PAT }} | |
| - name: Bump MCP server version | |
| id: mcp_version | |
| if: inputs.target == 'mcp-server' || inputs.target == 'both' | |
| run: | | |
| PKG="packages/mcp-server/package.json" | |
| LOCAL=$(node -p "require('./$PKG').version") | |
| # Query npm for the latest published version | |
| PUBLISHED=$(npm view airtable-user-mcp version 2>/dev/null || echo "0.0.0") | |
| # External input interpolated into node -e below — strict semver only | |
| if ! [[ "$PUBLISHED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "::warning::npm returned non-semver version '${PUBLISHED}' — ignoring" | |
| PUBLISHED="0.0.0" | |
| fi | |
| echo "Local: ${LOCAL}, npm: ${PUBLISHED}" | |
| # Pick the higher version as the base for bumping | |
| BASE=$(node -e " | |
| const a = '${LOCAL}'.split('.').map(Number); | |
| const b = '${PUBLISHED}'.split('.').map(Number); | |
| const cmp = a[0]-b[0] || a[1]-b[1] || a[2]-b[2]; | |
| console.log(cmp >= 0 ? '${LOCAL}' : '${PUBLISHED}'); | |
| ") | |
| NEXT=$(node -e " | |
| const [major, minor, patch] = '${BASE}'.split('.').map(Number); | |
| const bump = '${{ inputs.bump }}'; | |
| if (bump === 'major') console.log((major+1)+'.0.0'); | |
| else if (bump === 'minor') console.log(major+'.'+(minor+1)+'.0'); | |
| else console.log(major+'.'+minor+'.'+(patch+1)); | |
| ") | |
| echo "current=${BASE}" >> $GITHUB_OUTPUT | |
| echo "next=${NEXT}" >> $GITHUB_OUTPUT | |
| # Write new version to package.json AND to server.json. | |
| # | |
| # server.json is the MCP Registry manifest and carries its own version in | |
| # two places. check-tool-sync asserts server.json.version === package.json | |
| # .version, and `pnpm build` runs that guard first — so bumping only | |
| # package.json here fails the build on EVERY mcp-server release. It cannot | |
| # be worked around by hand-bumping server.json before dispatch either: the | |
| # BASE above is max(local, published), so the workflow always lands one | |
| # above whatever is committed. The bump has to happen here, together. | |
| node -e " | |
| const fs = require('fs'); | |
| const p = '$PKG'; | |
| const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); | |
| pkg.version = '${NEXT}'; | |
| fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); | |
| const sp = 'packages/mcp-server/server.json'; | |
| const srv = JSON.parse(fs.readFileSync(sp, 'utf8')); | |
| srv.version = '${NEXT}'; | |
| for (const entry of srv.packages ?? []) entry.version = '${NEXT}'; | |
| fs.writeFileSync(sp, JSON.stringify(srv, null, 2) + '\n'); | |
| " | |
| echo "MCP server: ${BASE} → ${NEXT}" | |
| - name: Bump LSP server version | |
| id: lsp_version | |
| if: inputs.target == 'lsp-server' || inputs.target == 'both' | |
| run: | | |
| PKG="packages/lsp-server/package.json" | |
| LOCAL=$(node -p "require('./$PKG').version") | |
| # Query npm for the latest published version | |
| PUBLISHED=$(npm view airtable-user-lsp version 2>/dev/null || echo "0.0.0") | |
| # External input interpolated into node -e below — strict semver only | |
| if ! [[ "$PUBLISHED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "::warning::npm returned non-semver version '${PUBLISHED}' — ignoring" | |
| PUBLISHED="0.0.0" | |
| fi | |
| echo "Local: ${LOCAL}, npm: ${PUBLISHED}" | |
| # Pick the higher version as the base for bumping | |
| BASE=$(node -e " | |
| const a = '${LOCAL}'.split('.').map(Number); | |
| const b = '${PUBLISHED}'.split('.').map(Number); | |
| const cmp = a[0]-b[0] || a[1]-b[1] || a[2]-b[2]; | |
| console.log(cmp >= 0 ? '${LOCAL}' : '${PUBLISHED}'); | |
| ") | |
| NEXT=$(node -e " | |
| const [major, minor, patch] = '${BASE}'.split('.').map(Number); | |
| const bump = '${{ inputs.bump }}'; | |
| if (bump === 'major') console.log((major+1)+'.0.0'); | |
| else if (bump === 'minor') console.log(major+'.'+(minor+1)+'.0'); | |
| else console.log(major+'.'+minor+'.'+(patch+1)); | |
| ") | |
| echo "current=${BASE}" >> $GITHUB_OUTPUT | |
| echo "next=${NEXT}" >> $GITHUB_OUTPUT | |
| # Write new version to package.json | |
| node -e " | |
| const fs = require('fs'); | |
| const p = '$PKG'; | |
| const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); | |
| pkg.version = '${NEXT}'; | |
| fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); | |
| " | |
| echo "LSP server: ${BASE} → ${NEXT}" | |
| # ── Build & Test ─────────────────────────────────────────── | |
| - name: Build all packages | |
| run: pnpm build | |
| - name: Run tests | |
| run: pnpm test | |
| # ── Package extension VSIX (one per platform target) ─────── | |
| # | |
| # `impit` and `@ngrok/ngrok` keep their compiled .node binary in separate | |
| # per-platform npm packages, so a single untargeted VSIX built on this | |
| # ubuntu runner would ship Linux-x64 binaries to every user and break | |
| # `mcp.httpClient: "impit"` and the ngrok tunnel everywhere else. We build | |
| # one VSIX per target instead, each vendoring only its own platform | |
| # packages (pinned to pnpm-lock.yaml by version AND integrity), and assert | |
| # the contents of every artifact before anything can be published. | |
| # The target matrix lives in scripts/vsix-targets.mjs. | |
| # | |
| # This runs on ONE runner rather than a job matrix: the foreign binaries | |
| # are fetched as plain tarballs (nothing is compiled), which avoids | |
| # needing arm64/alpine runners and keeps the version-bump → publish → | |
| # commit → tag sequence below in a single, ordered job. | |
| - name: Package platform-specific VSIXes | |
| id: vsix | |
| if: inputs.target == 'extension' || inputs.target == 'both' | |
| run: | | |
| # Copy README: strip SVGs + collapse <picture> to single <img> for VS Code Marketplace | |
| node -e " | |
| const fs = require('fs'); | |
| let readme = fs.readFileSync('README.md', 'utf8'); | |
| // Replace extension icon SVG with PNG | |
| readme = readme.replace(/<img src=\"[^\"]*airtable\\.svg\"[^/]*\\/>/, | |
| '<img src=\"https://raw.githubusercontent.com/Automations-Project/VSCode-Airtable-Formula/main/packages/extension/images/icon.png\" alt=\"Airtable Formula\" width=\"80\" />'); | |
| // Replace claude SVG row in table | |
| readme = readme.replace(/\| <img src=\"[^\"]*claude\\.svg\"[^|]*\|[^\n]*\n/, | |
| '| Claude Desktop | Claude Code | Cursor | Windsurf | Cline | Amp |\n'); | |
| // Remove mcp SVG icons | |
| readme = readme.replace(/<img src=\"[^\"]*mcp\\.svg\"[^/]*\\/>/g, ''); | |
| // Collapse all <picture> blocks to plain <img> — use the light PNG for the marketplace (white bg) | |
| // Strip -light/-dark variant suffix before swapping .svg → .png (only banner.png / architecture.png exist) | |
| readme = readme.replace(/<picture>[\s\S]*?<img src=\"([^\"]+)\"([^/]*)\/>[\s\S]*?<\/picture>/g, | |
| (_, src, attrs) => '<img src=\"' + src.replace(/-(light|dark)\.svg$/, '.png').replace(/\.svg$/, '.png') + '\"' + attrs + '/>'); | |
| fs.writeFileSync('packages/extension/README.md', readme); | |
| " | |
| # Eight target artifact packaging/assertion smokes: build one VSIX per | |
| # published target and verify the CONTENTS of each — every .node | |
| # matched byte-for-byte against scripts/native-binary-digests.json, | |
| # which was recorded from tarballs verified against pnpm-lock.yaml. | |
| # package-targets.mjs aborts on the first target that fails, so a bad | |
| # artifact can never reach a publish step. | |
| # | |
| # These are packaging smokes, not runtime smokes. A single runner can | |
| # only ever `require()` the binding built for itself, so only the host | |
| # target's binding could receive a genuine runtime load; the other | |
| # seven are verified by exact content, which is what the digests are | |
| # for. Nothing here should be described as runtime-loading eight | |
| # native bindings. | |
| node scripts/package-targets.mjs --out-dir=artifacts | |
| # Re-assert the finished set: every published target present, no | |
| # untargeted artifact, right binaries — byte-exact — in each. | |
| node scripts/assert-vsix-binaries.mjs --dir=artifacts | |
| ls -la artifacts/ | |
| # ── Publish extension ────────────────────────────────────── | |
| # Every artifact is published; there is deliberately NO untargeted | |
| # fallback. A user on a platform we do not build for sees the extension | |
| # as unavailable rather than installing one with the wrong binaries. | |
| - name: Publish extension to VS Code Marketplace | |
| if: | | |
| !inputs.dry_run && | |
| (inputs.target == 'extension' || inputs.target == 'both') | |
| run: | | |
| ARGS=() | |
| COUNT=0 | |
| for f in artifacts/*.vsix; do | |
| # Two argv elements per file, so ${#ARGS[@]} is not the build count. | |
| ARGS+=(--packagePath "$f") | |
| COUNT=$((COUNT + 1)) | |
| done | |
| echo "Publishing ${COUNT} platform builds to the Marketplace..." | |
| pnpm exec vsce publish "${ARGS[@]}" | |
| env: | |
| VSCE_PAT: ${{ secrets.VSCE_PAT }} | |
| - name: Publish extension to Open VSX | |
| if: | | |
| !inputs.dry_run && | |
| (inputs.target == 'extension' || inputs.target == 'both') | |
| run: | | |
| for f in artifacts/*.vsix; do | |
| echo "Publishing $(basename "$f") to Open VSX..." | |
| pnpm exec ovsx publish "$f" --pat "$OVSX_PAT" | |
| done | |
| # Verify each target actually landed (Open VSX indexes asynchronously). | |
| # Target list comes from the same source of truth the build used. | |
| VERSION="${{ steps.ext_version.outputs.next }}" | |
| TARGETS=$(node -e "import('./scripts/vsix-targets.mjs').then(m=>console.log(m.ALL_TARGETS.join(' ')))") | |
| # ONE wall-clock budget shared by all targets, enforced whether or not | |
| # anything has been confirmed yet. A per-target budget would multiply | |
| # by the number of targets, so an index that never catches up (queued | |
| # publish, namespace problem) would sleep ~24 minutes and emit a | |
| # warning per target. This is a best-effort check that never fails the | |
| # release, so it must not dominate the job's runtime. | |
| VERIFY_BUDGET=180 | |
| VERIFY_START=$(date +%s) | |
| UNVERIFIED=() | |
| for TARGET in $TARGETS; do | |
| echo "Verifying Open VSX indexed v${VERSION} (${TARGET})..." | |
| FOUND="" | |
| while true; do | |
| FOUND=$(curl -s "https://open-vsx.org/api/Nskha/airtable-formula/${TARGET}/${VERSION}" \ | |
| | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{ | |
| try{const p=JSON.parse(d);console.log(p.version||'not-found')} | |
| catch{console.log('error')} | |
| })") | |
| if [ "$FOUND" = "$VERSION" ]; then | |
| echo "✓ Open VSX confirmed: v${VERSION} (${TARGET})" | |
| break | |
| fi | |
| if [ $(( $(date +%s) - VERIFY_START )) -ge "$VERIFY_BUDGET" ]; then | |
| UNVERIFIED+=("$TARGET") | |
| break | |
| fi | |
| echo " not indexed yet, waiting 10s..." | |
| sleep 10 | |
| done | |
| done | |
| ELAPSED=$(( $(date +%s) - VERIFY_START )) | |
| if [ ${#UNVERIFIED[@]} -gt 0 ]; then | |
| echo "::warning::Open VSX did not index v${VERSION} for ${UNVERIFIED[*]} within ${ELAPSED}s — indexing may still be in progress (publish succeeded)" | |
| fi | |
| env: | |
| OVSX_PAT: ${{ secrets.OVSX_PAT }} | |
| # ── Publish MCP server ───────────────────────────────────── | |
| - name: Publish MCP server to npm | |
| if: | | |
| !inputs.dry_run && | |
| (inputs.target == 'mcp-server' || inputs.target == 'both') | |
| run: | | |
| npm config set //registry.npmjs.org/:_authToken "${NPM_TOKEN}" | |
| npm whoami && echo "npm auth OK" || echo "WARNING: npm whoami failed — verify NPM_TOKEN secret" | |
| cd packages/mcp-server | |
| npm publish --provenance --access public | |
| env: | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| # ── Publish LSP server ───────────────────────────────────── | |
| - name: Publish LSP server to npm | |
| if: | | |
| !inputs.dry_run && | |
| (inputs.target == 'lsp-server' || inputs.target == 'both') | |
| run: | | |
| npm config set //registry.npmjs.org/:_authToken "${NPM_TOKEN}" | |
| cd packages/lsp-server | |
| npm publish --provenance --access public | |
| env: | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| # ── Commit, tag, push ────────────────────────────────────── | |
| - name: Commit version bump and create tags | |
| if: "!inputs.dry_run" | |
| run: | | |
| TARGETS="" | |
| TAGS="" | |
| # Stage modified package.json files | |
| if [[ "${{ inputs.target }}" == "extension" || "${{ inputs.target }}" == "both" ]]; then | |
| git add packages/extension/package.json | |
| TARGETS="${TARGETS} extension v${{ steps.ext_version.outputs.next }}" | |
| TAGS="${TAGS} extension/v${{ steps.ext_version.outputs.next }}" | |
| fi | |
| if [[ "${{ inputs.target }}" == "mcp-server" || "${{ inputs.target }}" == "both" ]]; then | |
| # server.json is bumped alongside package.json (see the bump step) and | |
| # must be committed with it, or the next build fails the version guard. | |
| git add packages/mcp-server/package.json packages/mcp-server/server.json | |
| TARGETS="${TARGETS} mcp-server v${{ steps.mcp_version.outputs.next }}" | |
| TAGS="${TAGS} mcp-server/v${{ steps.mcp_version.outputs.next }}" | |
| fi | |
| if [[ "${{ inputs.target }}" == "lsp-server" || "${{ inputs.target }}" == "both" ]]; then | |
| git add packages/lsp-server/package.json | |
| TARGETS="${TARGETS} lsp-server v${{ steps.lsp_version.outputs.next }}" | |
| TAGS="${TAGS} lsp-server/v${{ steps.lsp_version.outputs.next }}" | |
| fi | |
| # Commit | |
| git commit -m "release:${TARGETS}" | |
| # Create tags | |
| for TAG in $TAGS; do | |
| git tag "$TAG" | |
| done | |
| # Push commit and tags | |
| git push origin main --tags | |
| # ── GitHub Releases ──────────────────────────────────────── | |
| - name: Create extension GitHub Release | |
| if: | | |
| !inputs.dry_run && | |
| (inputs.target == 'extension' || inputs.target == 'both') | |
| run: | | |
| # Attach every platform build so users can sideload the right one. | |
| gh release create "extension/v${{ steps.ext_version.outputs.next }}" \ | |
| artifacts/*.vsix \ | |
| --title "Extension v${{ steps.ext_version.outputs.next }}" \ | |
| --generate-notes \ | |
| --target main | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create MCP server GitHub Release | |
| if: | | |
| !inputs.dry_run && | |
| (inputs.target == 'mcp-server' || inputs.target == 'both') | |
| run: | | |
| gh release create "mcp-server/v${{ steps.mcp_version.outputs.next }}" \ | |
| --title "MCP Server v${{ steps.mcp_version.outputs.next }}" \ | |
| --generate-notes \ | |
| --target main | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Create LSP server GitHub Release | |
| if: | | |
| !inputs.dry_run && | |
| (inputs.target == 'lsp-server' || inputs.target == 'both') | |
| run: | | |
| gh release create "lsp-server/v${{ steps.lsp_version.outputs.next }}" \ | |
| --title "LSP Server v${{ steps.lsp_version.outputs.next }}" \ | |
| --generate-notes \ | |
| --target main | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # ── Summary ──────────────────────────────────────────────── | |
| - name: Summary | |
| if: always() | |
| run: | | |
| echo "## Release Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| if [[ "${{ inputs.dry_run }}" == "true" ]]; then | |
| echo "**DRY RUN — nothing published**" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| if [[ "${{ inputs.target }}" == "extension" || "${{ inputs.target }}" == "both" ]]; then | |
| echo "- Extension: ${{ steps.ext_version.outputs.current }} → **${{ steps.ext_version.outputs.next }}**" >> $GITHUB_STEP_SUMMARY | |
| if [[ -d artifacts ]]; then | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo " Platform builds (no untargeted fallback is published):" >> $GITHUB_STEP_SUMMARY | |
| for f in artifacts/*.vsix; do | |
| echo " - \`$(basename "$f")\`" >> $GITHUB_STEP_SUMMARY | |
| done | |
| fi | |
| fi | |
| if [[ "${{ inputs.target }}" == "mcp-server" || "${{ inputs.target }}" == "both" ]]; then | |
| echo "- MCP Server: ${{ steps.mcp_version.outputs.current }} → **${{ steps.mcp_version.outputs.next }}**" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| if [[ "${{ inputs.target }}" == "lsp-server" || "${{ inputs.target }}" == "both" ]]; then | |
| echo "- LSP Server: ${{ steps.lsp_version.outputs.current }} → **${{ steps.lsp_version.outputs.next }}**" >> $GITHUB_STEP_SUMMARY | |
| fi |