diff --git a/.github/workflows/publish.dispatch.yml b/.github/workflows/publish.dispatch.yml new file mode 100644 index 00000000..c48b585c --- /dev/null +++ b/.github/workflows/publish.dispatch.yml @@ -0,0 +1,72 @@ +name: Publish NPM (Manual) + +on: + workflow_dispatch: + inputs: + release-tag: + type: string + required: true + description: Release Tag to Publish + +jobs: + validate_tag: + runs-on: ubuntu-latest + outputs: + is-prerelease: ${{ steps.validate-release.outputs.is-prerelease }} + steps: + - uses: actions/github-script@v7 + id: validate-release + with: + script: | + /** the "core" module does not have access to workflow_dispatch inputs */ + const tag = '${{ inputs.release-tag }}'; + + /** Releases don't have a guaranteed order, so we'll have to paginate */ + let exhausted = false; + let page = 1; + while (!exhausted) { + const releases = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + page, + per_page: 100, + }).then(r => r.data); + + const matchingRelease = releases.find(r => r.tag_name === tag); + if (matchingRelease) { + core.setOutput('has-release', 'true'); + core.setOutput('is-prerelease', matchingRelease.prerelease.toString()); + return; + } + + if (releases.length < 100) { + exhausted = true; + } else if (page >= 10) { + throw new Error("We iterated over 10 pages. Does the script work?"); + } else { + page++ + } + + } + + core.setOutput('has-release', 'false'); + core.setOutput('is-prerelease', 'false'); + + - name: Abort + if: steps.validate-release.outputs.has-release != 'true' + run: | + { + echo "Tag ${{ github.event.inputs.release_tag }} not found." + exit 1 + } + + publish_npm: + needs: validate_tag + uses: ./.github/workflows/publish.reusable.yml + permissions: + contents: write + id-token: write + with: + release-tag: ${{ github.event.inputs.release-tag }} + is-prerelease: ${{ needs.validate_tag.outputs.is-prerelease }} + secrets: inherit diff --git a/.github/workflows/publish.reusable.yml b/.github/workflows/publish.reusable.yml new file mode 100644 index 00000000..fb0ff8fa --- /dev/null +++ b/.github/workflows/publish.reusable.yml @@ -0,0 +1,68 @@ +name: Publish to NPM & Brew + +on: + workflow_call: + inputs: + release-tag: + type: string + required: true + is-prerelease: + type: string + required: true + default: "false" + +jobs: + publish: + name: Publish All the Things + runs-on: ubuntu-latest + permissions: + contents: write + # ? what's this?! required for executing the node script? + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + registry-url: "https://registry.npmjs.org" + + - name: Generate Packages + id: generate-packages + run: node packages/@pglt/pglt/scripts/generate-packages.mjs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.release-tag }} + PRERELEASE: ${{ inputs.is-prerelease }} + + - name: Verify NPM TOKEN exists + run: | + if [ -z "${{ secrets.NPM_TOKEN }}" ]; then + echo "Secret is not defined" + exit 1 + else + echo "Secret is defined" + fi + + - name: Print package.json + run: | + cat packages/@pglt/pglt/package.json + + - name: Publish npm packages as nightly + if: inputs.is-prerelease == 'true' + run: | + for package in packages/@pglt/*; do + npm publish $package --tag nightly --access public --provenance + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # + + - name: Publish npm packages as latest + if: inputs.is-prerelease != 'true' + run: | + for package in packages/@pglt/*; do + npm publish $package --tag latest --access public --provenance + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish.trigger.yml b/.github/workflows/publish.trigger.yml new file mode 100644 index 00000000..b2a102a5 --- /dev/null +++ b/.github/workflows/publish.trigger.yml @@ -0,0 +1,16 @@ +name: Publish NPM (Automatic) + +on: + release: + types: [released, prereleased] + +jobs: + publish_npm: + uses: ./.github/workflows/publish.reusable.yml + permissions: + contents: write + id-token: write + with: + release-tag: ${{ github.event.release.tag_name }} + is-prerelease: ${{ github.event.release.prerelease }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4bc1b90..882dd01d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,7 +130,7 @@ jobs: fail_on_unmatched_files: true draft: true - - name: ✅ Output Link to Worflow Summary + - name: ✅ Output Link to Workflow Summary run: | { echo "# 🚀 Release completed!" diff --git a/packages/@pglt/pglt/bin/pglt b/packages/@pglt/pglt/bin/pglt new file mode 100644 index 00000000..4f010161 --- /dev/null +++ b/packages/@pglt/pglt/bin/pglt @@ -0,0 +1,46 @@ +#!/usr/bin/env node +const { platform, arch, env } = process; + +/** + * platform and arch are values injected into the node runtime. + * We use the values documented on https://nodejs.org. + */ +const PLATFORMS = { + win32: { + x64: "pglt-x86_64-windows-msvc/pglt.exe", + arm64: "pglt-aarch64-windows-msvc/pglt.exe", + }, + darwin: { + x64: "pglt-x86_64-apple-darwin/pglt", + arm64: "pglt-aarch64-apple-darwin/pglt", + }, + linux: { + x64: "pglt-x86_64-linux-gnu/pglt", + arm64: "pglt-aarch64-linux-gnu/pglt", + }, +}; + +const binPath = env.PGLT_BINARY || PLATFORMS?.[platform]?.[arch]; + +if (binPath) { + const result = require("child_process").spawnSync( + require.resolve(binPath), + process.argv.slice(2), + { + shell: false, + stdio: "inherit", + env, + } + ); + + if (result.error) { + throw result.error; + } + + process.exitCode = result.status; +} else { + console.error( + "The pglt CLI package doesn't ship with prebuilt binaries for your platform yet. Please file an issue in the main repository." + ); + process.exitCode = 1; +} diff --git a/packages/@pglt/pglt/package.json b/packages/@pglt/pglt/package.json new file mode 100644 index 00000000..264ee54c --- /dev/null +++ b/packages/@pglt/pglt/package.json @@ -0,0 +1,43 @@ +{ + "name": "pglt", + "version": "", + "bin": { + "pglt": "bin/pglt" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/supabase-community/postgres_lsp.git", + "directory": "packages/@pglt/pglt" + }, + "author": "Supabase Community", + "contributors": [ + { + "name": "Philipp Steinrötter", + "url": "https://github.com/psteinroe" + }, + { + "name": "Julian Domke", + "url": "https://github.com/juleswritescode" + } + ], + "license": "MIT or Apache-2.0", + "description": "A collection of language tools and a Language Server Protocol (LSP) implementation for Postgres, focusing on developer experience and reliable SQL tooling.", + "files": [ + "bin/pglt", + "schema.json" + ], + "engines": { + "node": ">=20" + }, + "publishConfig": { + "provenance": true + }, + "optionalDependencies": { + "pglt-x86_64-windows-msvc": "", + "pglt-aarch64-windows-msvc": "", + "pglt-x86_64-apple-darwin": "", + "pglt-aarch64-apple-darwin": "", + "pglt-x86_64-linux-gnu": "", + "pglt-aarch64-linux-gnu": "" + } +} diff --git a/packages/@pglt/pglt/scripts/generate-packages.mjs b/packages/@pglt/pglt/scripts/generate-packages.mjs new file mode 100644 index 00000000..2a1fd2f4 --- /dev/null +++ b/packages/@pglt/pglt/scripts/generate-packages.mjs @@ -0,0 +1,241 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import { pipeline } from "node:stream"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +const streamPipeline = promisify(pipeline); + +const CLI_ROOT = resolve(fileURLToPath(import.meta.url), "../.."); +const PACKAGES_PGLT_ROOT = resolve(CLI_ROOT, ".."); +const PGLT_ROOT = resolve(PACKAGES_PGLT_ROOT, "../.."); +const MANIFEST_PATH = resolve(CLI_ROOT, "package.json"); +const SUPPORTED_PLATFORMS = [ + "pc-windows-msvc", + "apple-darwin", + "unknown-linux-gnu", +]; +const SUPPORTED_ARCHITECTURES = ["x86_64", "aarch64"]; + +async function downloadSchema(releaseTag, githubToken) { + const assetUrl = `https://github.com/supabase-community/postgres_lsp/releases/download/${releaseTag}/schema.json`; + + const response = await fetch(assetUrl.trim(), { + headers: { + Authorization: `token ${githubToken}`, + Accept: `application/octet-stream`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to Fetch Asset from ${assetUrl}`); + } + + // download to root. + const fileStream = fs.createWriteStream(resolve(PGLT_ROOT, "schema.json")); + + await streamPipeline(response.body, fileStream); + + console.log(`Downloaded schema for ${releaseTag}`); +} + +async function downloadBinary(platform, arch, os, releaseTag, githubToken) { + const buildName = getBuildName(platform, arch); + + const assetUrl = `https://github.com/supabase-community/postgres_lsp/releases/download/${releaseTag}/${buildName}`; + + const response = await fetch(assetUrl.trim(), { + headers: { + Authorization: `token ${githubToken}`, + Accept: `application/octet-stream`, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error( + `Failed to Fetch Asset from ${assetUrl} (Reason: ${error})` + ); + } + + // just download to root. + const fileStream = fs.createWriteStream(getBinarySource(platform, arch, os)); + + await streamPipeline(response.body, fileStream); + + console.log(`Downloaded asset for ${buildName} (v${releaseTag})`); +} + +async function overwriteManifestVersions(releaseTag, isPrerelease) { + const version = getVersion(releaseTag, isPrerelease); + + const manifestClone = structuredClone(rootManifest()); + + manifestClone.version = version; + for (const dep in manifestClone.optionalDependencies) { + manifestClone.optionalDependencies[dep] = version; + } + + /** + * writeFileSync seemed to not work reliably? + */ + await new Promise((res, rej) => { + fs.writeFile(MANIFEST_PATH, JSON.stringify(manifestClone, null, 2), (e) => + e ? rej(e) : res() + ); + }); +} + +async function makePackageDir(platform, arch) { + const buildName = getBuildName(platform, arch); + const packageRoot = resolve(PACKAGES_PGLT_ROOT, buildName); + + await new Promise((res, rej) => { + fs.mkdir(packageRoot, {}, (e) => (e ? rej(e) : res())); + }); +} + +function copyBinaryToNativePackage(platform, arch, os) { + // Update the package.json manifest + const buildName = getBuildName(platform, arch); + const packageRoot = resolve(PACKAGES_PGLT_ROOT, buildName); + const packageName = getPackageName(platform, arch); + + const { version, license, repository, engines } = rootManifest(); + + /** + * We need to map rust triplets to NPM-known values. + * Otherwise, npm will abort the package installation. + */ + const npm_arch = arch === "aarch64" ? "arm64" : "x64"; + let libc = undefined; + let npm_os = undefined; + + switch (os) { + case "linux": { + libc = "gnu"; + npm_os = "linux"; + break; + } + case "windows": { + libc = "msvc"; + npm_os = "win32"; + break; + } + case "darwin": { + libc = undefined; + npm_os = "darwin"; + break; + } + default: { + throw new Error(`Unsupported os: ${os}`); + } + } + + const manifest = JSON.stringify( + { + name: packageName, + version, + license, + repository, + engines, + os: [npm_os], + cpu: [npm_arch], + libc, + }, + null, + 2 + ); + + const ext = getBinaryExt(os); + const manifestPath = resolve(packageRoot, "package.json"); + console.info(`Update manifest ${manifestPath}`); + fs.writeFileSync(manifestPath, manifest); + + // Copy the CLI binary + const binarySource = getBinarySource(platform, arch, os); + const binaryTarget = resolve(packageRoot, `pglt${ext}`); + + if (!fs.existsSync(binarySource)) { + console.error( + `Source for binary for ${buildName} not found at: ${binarySource}` + ); + process.exit(1); + } + + console.info(`Copy binary ${binaryTarget}`); + fs.copyFileSync(binarySource, binaryTarget); + fs.chmodSync(binaryTarget, 0o755); +} + +function copySchemaToNativePackage(platform, arch) { + const buildName = getBuildName(platform, arch); + const packageRoot = resolve(PACKAGES_PGLT_ROOT, buildName); + + const schemaSrc = resolve(PGLT_ROOT, `schema.json`); + const schemaTarget = resolve(packageRoot, `schema.json`); + + if (!fs.existsSync(schemaSrc)) { + console.error(`schema.json not found at: ${schemaSrc}`); + process.exit(1); + } + + console.info(`Copying schema.json`); + fs.copyFileSync(schemaSrc, schemaTarget); + fs.chmodSync(schemaTarget, 0o666); +} + +const rootManifest = () => + JSON.parse(fs.readFileSync(MANIFEST_PATH).toString("utf-8")); + +function getBinaryExt(os) { + return os === "windows" ? ".exe" : ""; +} + +function getBinarySource(platform, arch, os) { + const ext = getBinaryExt(os); + return resolve(PGLT_ROOT, `${getBuildName(platform, arch)}${ext}`); +} + +function getBuildName(platform, arch) { + return `pglt_${arch}-${platform}`; +} + +function getPackageName(platform, arch) { + // trim the "unknown" from linux and the "pc" from windows + const platformName = platform.split("-").slice(-2).join("-"); + return `pglt-${arch}-${platformName}`; +} + +function getOs(platform) { + return platform.split("-").find((_, idx) => idx === 1); +} + +function getVersion(releaseTag, isPrerelease) { + return releaseTag + (isPrerelease ? "-rc" : ""); +} + +(async function main() { + const githubToken = process.env.GITHUB_TOKEN; + let releaseTag = process.env.RELEASE_TAG; + assert(githubToken, "GITHUB_TOKEN not defined!"); + assert(releaseTag, "RELEASE_TAG not defined!"); + + const isPrerelease = process.env.PRERELEASE === "true"; + + await downloadSchema(releaseTag, githubToken); + await overwriteManifestVersions(releaseTag, isPrerelease); + + for (const platform of SUPPORTED_PLATFORMS) { + const os = getOs(platform); + + for (const arch of SUPPORTED_ARCHITECTURES) { + await makePackageDir(platform, arch); + await downloadBinary(platform, arch, os, releaseTag, githubToken); + copyBinaryToNativePackage(platform, arch, os); + copySchemaToNativePackage(platform, arch); + } + } + + process.exit(0); +})();