Skip to content

release: 0.1.4

release: 0.1.4 #12

Workflow file for this run

# One workflow cuts an entire `v*` release, and the public GitHub Release is the LAST thing it does.
# build hands the platform binaries to the downstream jobs as workflow artifacts (not through the
# Release), every channel — npm, the GHCR runtime image, Open VSX, the VS Marketplace — publishes,
# and only once they have all succeeded does `finalize` create the GitHub Release with every asset
# attached. So a half-published release is never visible: if any channel fails, there is no Release.
#
# Replaces the former fan-out (release-katari / release-npm / release-katari-runtime / release-vsix /
# publish-vsix-marketplace), which created the Release first and hung the rest off it.
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Release tag (vX.Y.Z[-prerelease])"
required: true
permissions:
contents: read
jobs:
verify-versions:
runs-on: ubuntu-22.04
steps:
- name: Resolve tag
id: tag
shell: bash
run: |
TAG="${{ inputs.tag || github.ref_name }}"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then
echo "release: '$TAG' is not a vX.Y.Z[-prerelease] tag" >&2
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# A suffixed tag (…-rc1) is a prerelease: npm gets a non-latest dist-tag, the runtime image
# skips :latest, and the extension is not pushed to the Marketplace (vsce rejects a semver
# prerelease suffix at publish time).
if [[ "$TAG" == *-* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Verify the tree matches the tag
run: node scripts/verify-versions.mjs --tag "${{ steps.tag.outputs.tag }}"
outputs:
tag: ${{ steps.tag.outputs.tag }}
prerelease: ${{ steps.tag.outputs.prerelease }}
build:
needs: verify-versions
strategy:
matrix:
include:
# Must stay in sync with scripts/versions-common.mjs BINARY_PLATFORMS and the shim's
# supported set (typescript/cli/bin/katari.mjs).
- os: ubuntu-22.04
platform: linux-x64
- os: macos-14
platform: darwin-arm64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.verify-versions.outputs.tag }}
- uses: haskell-actions/setup@v2
id: setup
with:
ghc-version: "9.6.7"
enable-stack: true
stack-version: "3.7.1"
- name: Resolve cache key inputs
id: cache-key
shell: bash
run: echo "snapshot=$(grep '^snapshot:' stack.yaml | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache ~/.stack
uses: actions/cache@v4
with:
path: ${{ steps.setup.outputs.stack-root }}
key: ${{ runner.os }}-stack-v2-${{ steps.cache-key.outputs.snapshot }}-${{ hashFiles('stack.yaml', 'stack.yaml.lock', 'haskell/**/package.yaml') }}
restore-keys: |
${{ runner.os }}-stack-v2-${{ steps.cache-key.outputs.snapshot }}-
- name: Build
run: stack build katari-cli:katari katari-lsp:katari-lsp --copy-bins --local-bin-path ./bin
- name: Strip (linux)
if: runner.os == 'Linux'
run: strip ./bin/katari ./bin/katari-lsp
- name: Package
id: package
shell: bash
run: |
VERSION="${TAG#v}"
# The CLI tarball (consumed by publish-npm) and the LSP tarball (consumed by package-vsix)
# each hold a single binary; distinct name prefixes keep the two consumers' globs disjoint.
CLI_TARBALL="katari-$VERSION-${{ matrix.platform }}.tar.gz"
LSP_TARBALL="katari-lsp-$VERSION-${{ matrix.platform }}.tar.gz"
tar czf "$CLI_TARBALL" -C ./bin katari
tar czf "$LSP_TARBALL" -C ./bin katari-lsp
shasum -a 256 "$CLI_TARBALL" > "$CLI_TARBALL.sha256"
shasum -a 256 "$LSP_TARBALL" > "$LSP_TARBALL.sha256"
echo "cli_tarball=$CLI_TARBALL" >> "$GITHUB_OUTPUT"
echo "lsp_tarball=$LSP_TARBALL" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ needs.verify-versions.outputs.tag }}
- name: Upload the CLI tarball
uses: actions/upload-artifact@v4
with:
name: cli-${{ matrix.platform }}
path: |
${{ steps.package.outputs.cli_tarball }}
${{ steps.package.outputs.cli_tarball }}.sha256
- name: Upload the LSP tarball
uses: actions/upload-artifact@v4
with:
name: lsp-${{ matrix.platform }}
path: |
${{ steps.package.outputs.lsp_tarball }}
${{ steps.package.outputs.lsp_tarball }}.sha256
e2e:
# The wire-compatibility gate: the compiler's IR/schema output must still drive the runtime end to
# end (see e2e/README.md) before ANY channel publishes — a publish is otherwise irreversible. It
# reuses the linux-x64 binary the build job already produced (the suite honors KATARI_E2E_BIN
# instead of reinvoking stack), so the gate needs no Haskell toolchain — only node + docker, and
# docker is preinstalled on the GitHub-hosted runner.
needs: [verify-versions, build]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.verify-versions.outputs.tag }}
- name: Resolve version
id: version
shell: bash
run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ needs.verify-versions.outputs.tag }}
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- uses: pnpm/action-setup@v4
- name: Install
run: pnpm install --frozen-lockfile
- name: Download the linux-x64 CLI binary
uses: actions/download-artifact@v4
with:
name: cli-linux-x64
path: .cli
- name: Unpack the CLI binary
id: cli
shell: bash
run: |
tar xzf ".cli/katari-${{ steps.version.outputs.version }}-linux-x64.tar.gz" -C .cli katari
chmod +x .cli/katari
echo "bin=$GITHUB_WORKSPACE/.cli/katari" >> "$GITHUB_OUTPUT"
# The suite brings up its own compose services (postgres + s3mock), boots the runtime from source
# (tsx), and builds the sidecar bundler itself — so `pnpm install` plus the reused binary is all
# it needs.
- name: e2e
run: pnpm run test:e2e
env:
KATARI_E2E_BIN: ${{ steps.cli.outputs.bin }}
publish-npm:
needs: [verify-versions, build, e2e]
runs-on: ubuntu-22.04
permissions:
contents: read
id-token: write # npm Trusted Publishing (OIDC) — no NPM_TOKEN
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.verify-versions.outputs.tag }}
- name: Resolve version and dist-tag
id: version
shell: bash
run: |
VERSION="${TAG#v}"
# Stable -> latest. Pre-1.0, the rc line IS the supported line: the README's plain
# `npm install @katari-lang/cli` must resolve to the current rc, not to a months-old
# one (a `latest` pointing at rc3 while rc11 shipped is how every cold-start user got
# a toolchain that no current doc describes). So rc publishes as BOTH `rc` and `latest`
# is not expressible in one publish — `latest` wins, and the `rc` alias is retired.
# Other pre-release identifiers (beta, ...) keep publishing under their own tag so an
# experiment never becomes the default by accident. Revisit at 1.0: stable returns to
# owning `latest`, and rc goes back under `rc`.
if [[ "$VERSION" == *-* ]]; then
SUFFIX="${VERSION#*-}"
DIST_TAG="${SUFFIX%%[.0-9]*}"
DIST_TAG="${DIST_TAG:-next}"
if [[ "$DIST_TAG" == "rc" ]]; then
DIST_TAG="latest"
fi
else
DIST_TAG="latest"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "dist-tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ needs.verify-versions.outputs.tag }}
# Node 24+: npm >= 11.5.1 is required for OIDC Trusted Publishing.
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- uses: pnpm/action-setup@v4
- name: Install
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm -r --filter "./typescript/*" run build
- name: Download the CLI binaries
uses: actions/download-artifact@v4
with:
pattern: cli-*
path: .binaries
merge-multiple: true
- name: Stage the binary packages
run: node scripts/stage-binary-packages.mjs --version "${{ steps.version.outputs.version }}"
- name: Inject the shim's optionalDependencies
run: node scripts/bump-versions.mjs --version "${{ steps.version.outputs.version }}"
- name: Publish
shell: bash
run: |
set -euo pipefail
# A rerun after a partial publish must not trip over the packages that already made it:
# verify-versions pins the tree to the tag, so a version already on the registry IS this
# tag's build and skipping it is sound.
skip_published() {
local name version
name=$(node -p "require('./package.json').name")
version=$(node -p "require('./package.json').version")
if npm view "${name}@${version}" version >/dev/null 2>&1; then
echo "skip ${name}@${version} — already on the registry"
return 0
fi
return 1
}
# Library packages first, then the platform binaries, then the shim that ties them
# together — so a partially failed publish never leaves the shim pointing at nothing.
for dir in typescript/types typescript/port typescript/runtime typescript/bundle typescript/mcp; do
(cd "$dir" && { skip_published || pnpm publish --access public --no-git-checks --tag "$DIST_TAG"; })
done
for dir in .staged/*/; do
(cd "$dir" && { skip_published || npm publish --access public --tag "$DIST_TAG"; })
done
(cd typescript/cli && { skip_published || pnpm publish --access public --no-git-checks --tag "$DIST_TAG"; })
env:
DIST_TAG: ${{ steps.version.outputs.dist-tag }}
runtime-image:
needs: [verify-versions, e2e]
runs-on: ubuntu-22.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.verify-versions.outputs.tag }}
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute image tags
id: tags
shell: bash
run: |
VERSION="${TAG#v}"
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
IMAGE="ghcr.io/$OWNER/katari"
TAGS="$IMAGE:$VERSION"
# `:latest` moves only on a stable release, so a pre-release never becomes the default pull.
if [[ "$VERSION" != *-* ]]; then
TAGS="$TAGS,$IMAGE:latest"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ needs.verify-versions.outputs.tag }}
- uses: docker/build-push-action@v6
env:
# The auto-uploaded buildx build-record artifact is a debug trace that has failed to
# download and broken finalize; the image itself is the release output, so skip it.
DOCKER_BUILD_RECORD_UPLOAD: "false"
with:
context: .
file: typescript/runtime/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
package-vsix:
needs: [verify-versions, build, e2e]
runs-on: ubuntu-22.04
strategy:
# One platform-specific VSIX per binary, same targets as the build matrix. The VSIX is just a
# zip, so a Linux runner can package the darwin-arm64 target too (it only bundles that binary).
matrix:
platform: [linux-x64, darwin-arm64]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.verify-versions.outputs.tag }}
- name: Resolve version
id: version
shell: bash
run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ needs.verify-versions.outputs.tag }}
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- uses: pnpm/action-setup@v4
- name: Install
run: pnpm install --frozen-lockfile
- name: Download the katari-lsp binary for this platform
uses: actions/download-artifact@v4
with:
name: lsp-${{ matrix.platform }}
path: .lsp
- name: Bundle the server binary into the extension
shell: bash
run: |
# Place it where the extension resolves the bundled server (typescript/vscode/bin/katari-lsp);
# .vscodeignore keeps bin/ in the VSIX, so `vsce package` ships it.
mkdir -p typescript/vscode/bin
tar xzf ".lsp/katari-lsp-${{ steps.version.outputs.version }}-${{ matrix.platform }}.tar.gz" -C typescript/vscode/bin katari-lsp
chmod +x typescript/vscode/bin/katari-lsp
- name: Package the platform VSIX
id: package
shell: bash
run: |
VSIX="katari-vscode-${{ steps.version.outputs.version }}-${{ matrix.platform }}.vsix"
# `vsce package` runs `vscode:prepublish` (the esbuild bundle) itself; the bundled binary in
# bin/ is picked up because .vscodeignore does not exclude it.
pnpm --filter katari-vscode exec vsce package \
--no-dependencies \
--target "${{ matrix.platform }}" \
--out "$GITHUB_WORKSPACE/$VSIX"
echo "vsix=$VSIX" >> "$GITHUB_OUTPUT"
- name: Upload the VSIX
uses: actions/upload-artifact@v4
with:
name: vsix-${{ matrix.platform }}
path: ${{ steps.package.outputs.vsix }}
publish-ovsx:
needs: [verify-versions, package-vsix]
runs-on: ubuntu-22.04
strategy:
matrix:
platform: [linux-x64, darwin-arm64]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.verify-versions.outputs.tag }}
- name: Resolve version
id: version
shell: bash
run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ needs.verify-versions.outputs.tag }}
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Download the VSIX for this platform
uses: actions/download-artifact@v4
with:
name: vsix-${{ matrix.platform }}
path: .vsix
- name: Publish to Open VSX
shell: bash
env:
OVSX_PAT: ${{ secrets.OVSX_TOKEN }}
PRERELEASE: ${{ needs.verify-versions.outputs.prerelease }}
run: |
VSIX=".vsix/katari-vscode-${{ steps.version.outputs.version }}-${{ matrix.platform }}.vsix"
# --skip-duplicate keeps a re-run idempotent (a version+target already on Open VSX is a no-op).
# A suffixed tag ships as an Open VSX pre-release so it never outranks the stable line.
FLAGS=(--skip-duplicate)
if [[ "$PRERELEASE" == "true" ]]; then
FLAGS+=(--pre-release)
fi
npx --yes ovsx publish "$VSIX" --pat "$OVSX_PAT" "${FLAGS[@]}"
publish-marketplace:
needs: [verify-versions, package-vsix]
# vsce rejects a semver prerelease suffix outright, so the Marketplace step runs on stable tags
# only. (The VSIX is still built and attached to the Release for prereleases.)
if: needs.verify-versions.outputs.prerelease == 'false'
runs-on: ubuntu-22.04
# The federated credential in Entra ID is scoped to this environment; azure/login trades the
# workflow's OIDC token for a Marketplace-publishing Azure identity, so no PAT is stored.
environment: vscode-marketplace
permissions:
contents: read
id-token: write
strategy:
matrix:
platform: [linux-x64, darwin-arm64]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.verify-versions.outputs.tag }}
- name: Resolve version
id: version
shell: bash
run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ needs.verify-versions.outputs.tag }}
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- uses: pnpm/action-setup@v4
- name: Install
run: pnpm install --frozen-lockfile
- name: Download the VSIX for this platform
uses: actions/download-artifact@v4
with:
name: vsix-${{ matrix.platform }}
path: .vsix
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Publish to the VS Marketplace
shell: bash
run: |
VSIX=".vsix/katari-vscode-${{ steps.version.outputs.version }}-${{ matrix.platform }}.vsix"
# --azure-credential uses the identity azure/login established; --packagePath publishes the
# already-built VSIX (no repackage); --skip-duplicate makes a re-run a no-op.
pnpm --filter katari-vscode exec vsce publish \
--azure-credential \
--packagePath "$GITHUB_WORKSPACE/$VSIX" \
--skip-duplicate
finalize:
# The Release is cut here and nowhere else — only once every channel that had to run has. A
# skipped Marketplace (prerelease) is tolerated; a failure anywhere upstream leaves no Release.
needs: [verify-versions, publish-npm, runtime-image, publish-ovsx, publish-marketplace]
if: >-
always() &&
needs.publish-npm.result == 'success' &&
needs.runtime-image.result == 'success' &&
needs.publish-ovsx.result == 'success' &&
(needs.publish-marketplace.result == 'success' || needs.publish-marketplace.result == 'skipped')
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Download every release asset
uses: actions/download-artifact@v4
with:
# Only the assets this workflow uploaded on purpose — the buildx build-record artifact
# docker/build-push-action auto-uploads is a debug trace, not a release asset, and has
# proven undownloadable enough to fail the whole job.
pattern: "{cli,lsp,vsix}-*"
path: .assets
merge-multiple: true
- name: Create the GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.verify-versions.outputs.tag }}
prerelease: ${{ needs.verify-versions.outputs.prerelease == 'true' }}
generate_release_notes: true
files: .assets/*