Skip to content

Release

Release #54

Workflow file for this run

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