diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cce4800..ed11670 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,20 +1,23 @@ on: - push: - branches-ignore: - - 'trunk' + push: + branches-ignore: + - "trunk" jobs: - tests: - name: 'Run tests' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 - with: - go-version-file: './go.mod' - - uses: actions/setup-node@v3 - with: - node-version-file: './.nvmrc' - cache: npm - - run: npm ci - - run: npm test + tests: + name: "Run tests" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: "Setup Go" + uses: actions/setup-go@v4 + with: + go-version-file: "./go.mod" + - name: "Setup Node" + uses: actions/setup-node@v3 + with: + node-version-file: "./.nvmrc" + cache: npm + - run: npm ci + - run: npm test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 13062fc..20a323b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: version: - description: 'Version to release' + description: "Version to release" required: true permissions: @@ -22,13 +22,14 @@ jobs: run: | git config --global user.name '${{ github.actor }}' git config --global user.email '${{ github.actor }}@users.noreply.github.com' - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - name: Check version format run: | bin/check-version.pl - uses: actions/setup-go@v4 with: - go-version-file: './go.mod' + go-version-file: "./go.mod" - name: Build run: | bin/build-binaries.sh @@ -42,3 +43,22 @@ jobs: files: | out/* tag_name: ${{ env.BINARY_VERSION }} + + publish-to-npm: + needs: release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: "Setup Node" + uses: actions/setup-node@v3 + with: + node-version-file: "./.nvmrc" + cache: npm + - name: Download release files + run: | + gh release download ${{ github.event.inputs.version }} --dir out + - run: bin/build-npm-packages.sh + - name: "Setup npm" + run: "npm config set -- '//registry.npmjs.org/:_authToken' '${{ secrets.NPM_RELEASE_TOKEN }}'" + - run: bin/publish-npm-packages.sh diff --git a/.gitignore b/.gitignore index f17211c..8177b56 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/npm /out /test gen-elm-wrappers diff --git a/LICENSE b/LICENSE index 6a3191d..1c74047 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2023 Dave Hinton +Copyright 2023–2024 Dave Hinton Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 726ea88..16cd3d6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ like to use for the keys of a `Dict`, or the elements of a `Set`. Unfortunately, for both of these cases, your type has to be `comparable`, and custom types cannot be `comparable`. What to do? -Solution: add some config to your `elm.json`, run `gen-elm-wrappers`, +Solution: add a `gen-elm-wrappers.json`, run `gen-elm-wrappers`, and it will generate one or more Elm modules that wrap `Dict` or `Set`, so that you can use them with your custom type. @@ -13,9 +13,13 @@ so that you can use them with your custom type. I haven’t wrapped this all up nicely in an NPM package yet. -For now, you need to run: +You can [download prebuilt binaries](https://github.com/dave4420/gen-elm-wrappers/releases). +Each file is a self-contained executable for the appropriate platform. +Rename it to `gen-elm-wrappers` and move it to somewhere on your `$PATH`. -- `brew install go` +Or to install from source: + +- `brew install go` (or whatever the best way of installing Go on your laptop is) - don’t worry, you don’t need to know Go to _use_ this - `npm ci` - `npm test` @@ -122,7 +126,7 @@ I’m not looking for a job, no. - Full stack or backend - Permanent only (no contracting) - IC only (no line management) -- Remote (UK timezone ± 1 hour) or on-site/hybrid (London/Medway); not willing to relocate +- Remote (UK timezone ± an hour or two) or on-site/hybrid (London/Medway); not willing to relocate - I prefer to work with statically typed languages (e.g. Typescript, not plain Javascript) - not blockchain (except for catching crims), not ad tech (unless it’s surveillance-free), don’t really want to work for a hedge fund diff --git a/bin/build-binaries.sh b/bin/build-binaries.sh index ce93339..0d5edca 100755 --- a/bin/build-binaries.sh +++ b/bin/build-binaries.sh @@ -11,6 +11,7 @@ rm -rf out mkdir out list-binaries-to-build | while read GOOS GOARCH ; do + # keep in sync with build-npm-packages.sh case $GOOS in android|ios) # get an error when building for android diff --git a/bin/build-npm-packages.sh b/bin/build-npm-packages.sh new file mode 100755 index 0000000..021b3fa --- /dev/null +++ b/bin/build-npm-packages.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -euo pipefail + +: ${BINARY_VERSION?} + +rm -rf npm +mkdir npm + +package_name=gen-elm-wrappers + +# general approach taken from + +list-binaries-to-build() { + perl > npm/$package_name/arch-packages.json +comma='' + +list-binaries-to-build | while read GOOS GOARCH ; do + BINARY_EXT='' + # keep in sync with build-binaries.sh + case $GOOS in + aix) + process_platform=aix + ;; + darwin) + process_platform=darwin + ;; + freebsd) + process_platform=freebsd + ;; + linux) + process_platform=linux + ;; + openbsd) + process_platform=openbsd + ;; + windows) + process_platform=win32 + BINARY_EXT=.exe + ;; + *) + continue + esac + + case $GOARCH in + 386) + process_arch=ia32 + ;; + amd64) + process_arch=x64 + ;; + arm) + process_arch=arm + ;; + arm64) + process_arch=arm64 + ;; + s390x) + process_arch=s390 + ;; + loong64) + process_arch=loong64 + ;; + s390x) + process_arch=s390x + ;; + *) + continue + esac + + arch_package_name=@dave4420/$package_name-$process_platform-$process_arch + mkdir -p npm/$arch_package_name/bin + cat < npm/$arch_package_name/package.json +{ + "name": "$arch_package_name", + "version": "$BINARY_VERSION", + "os": [ "$process_platform" ], + "cpu": [ "$process_arch" ] +} +PACKAGE_JSON + cat < npm/$arch_package_name/README.md +You should not need to install this package directly. + +Instead, + +- either install [$package_name](https://www.npmjs.com/package/$package_name) +- or manually [download the binary for your architecture](https://github.com/dave4420/gen-elm-wrappers/releases/tag/$BINARY_VERSION) +README + cp \ + out/gen-elm-wrappers-$GOOS-$GOARCH-$BINARY_VERSION$BINARY_EXT \ + npm/$arch_package_name/bin/gen-elm-wrappers$BINARY_EXT + printf '%s\n "%s": "%s"' \ + "$comma" \ + "${process_platform}-${process_arch}" \ + $arch_package_name \ + >> npm/$package_name/arch-packages.json + comma=',' +done + +printf '\n}\n' >> npm/$package_name/arch-packages.json + +node -e ' + const pj = require("./package.json"); + pj.version = process.env.BINARY_VERSION; + delete pj.private; + pj.scripts = { + "postinstall": "node ./install.js" + }; + pj.bin = "bin/cli.js"; + delete pj.devDependencies; + pj.optionalDependencies = {}; + fs.readdirSync("npm").forEach((dir) => { + if (dir === "'$package_name'") { + return; + } + pj.optionalDependencies[dir] = process.env.BINARY_VERSION; + }); + process.stdout.write(JSON.stringify(pj, null, 2)); + process.stdout.write("\n"); +' > npm/$package_name/package.json diff --git a/bin/publish-npm-packages.sh b/bin/publish-npm-packages.sh new file mode 100755 index 0000000..bd5ad41 --- /dev/null +++ b/bin/publish-npm-packages.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -exuo pipefail +IFS=$'\n\t' + +cd npm + +for package in $(find . -type d -exec sh -c '[ -f {}/package.json ]' \; -print) ; do + # ordered so that the package with the shortest name is published last, + # after its dependencies + if ! npm publish $package ; then + printf 'Error code %d; hopefully already published?\n' $? + fi + npm view "$( + node -e " + const package = require('$package/package.json'); + process.stdout.write(`${package.name}@${package.version}\n`); + " + )" +done diff --git a/npm-main-template/bin/cli.js b/npm-main-template/bin/cli.js new file mode 100755 index 0000000..95ef71c --- /dev/null +++ b/npm-main-template/bin/cli.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +// Lookup table for all platforms and binary distribution packages +const BINARY_DISTRIBUTION_PACKAGES = require("./arch-packages.json"); + +// Windows binaries end with .exe so we need to special case them. +const binaryName = + process.platform === "win32" ? "gen-elm-wrappers.exe" : "gen-elm-wrappers"; + +// Determine package name for this platform +const platformSpecificPackageName = + BINARY_DISTRIBUTION_PACKAGES[`${process.platform}-${process.arch}`]; + +function getBinaryPath() { + try { + // Resolving will fail if the optionalDependency was not installed + return require.resolve(`${platformSpecificPackageName}/bin/${binaryName}`); + } catch (e) { + return require("path").join(__dirname, "..", binaryName); + } +} + +require("child_process").execFileSync(getBinaryPath(), process.argv.slice(2), { + stdio: "inherit", +}); diff --git a/npm-main-template/install.js b/npm-main-template/install.js new file mode 100644 index 0000000..e711f17 --- /dev/null +++ b/npm-main-template/install.js @@ -0,0 +1,120 @@ +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const https = require("https"); + +// Lookup table for all platforms and binary distribution packages +const BINARY_DISTRIBUTION_PACKAGES = require("./arch-packages.json"); + +// Adjust the version you want to install. You can also make this dynamic. +const BINARY_DISTRIBUTION_VERSION = require("./package.json").version; + +// Windows binaries end with .exe so we need to special case them. +const binaryName = + process.platform === "win32" ? "gen-elm-wrappers.exe" : "gen-elm-wrappers"; + +// Determine package name for this platform +const platformSpecificPackageName = + BINARY_DISTRIBUTION_PACKAGES[`${process.platform}-${process.arch}`]; + +// Compute the path we want to emit the fallback binary to +const fallbackBinaryPath = path.join(__dirname, binaryName); + +function makeRequest(url) { + return new Promise((resolve, reject) => { + https + .get(url, (response) => { + if (response.statusCode >= 200 && response.statusCode < 300) { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + } else if ( + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + // Follow redirects + makeRequest(response.headers.location).then(resolve, reject); + } else { + reject( + new Error( + `npm responded with status code ${response.statusCode} when downloading the package!` + ) + ); + } + }) + .on("error", (error) => { + reject(error); + }); + }); +} + +function extractFileFromTarball(tarballBuffer, filepath) { + // Tar archives are organized in 512 byte blocks. + // Blocks can either be header blocks or data blocks. + // Header blocks contain file names of the archive in the first 100 bytes, terminated by a null byte. + // The size of a file is contained in bytes 124-135 of a header block and in octal format. + // The following blocks will be data blocks containing the file. + let offset = 0; + while (offset < tarballBuffer.length) { + const header = tarballBuffer.subarray(offset, offset + 512); + offset += 512; + + const fileName = header.toString("utf-8", 0, 100).replace(/\0.*/g, ""); + const fileSize = parseInt( + header.toString("utf-8", 124, 136).replace(/\0.*/g, ""), + 8 + ); + + if (fileName === filepath) { + return tarballBuffer.subarray(offset, offset + fileSize); + } + + // Clamp offset to the uppoer multiple of 512 + offset = (offset + fileSize + 511) & ~511; + } +} + +async function downloadBinaryFromNpm() { + // Download the tarball of the right binary distribution package + const tarballDownloadBuffer = await makeRequest( + `https://registry.npmjs.org/${platformSpecificPackageName}/-/${platformSpecificPackageName}-${BINARY_DISTRIBUTION_VERSION}.tgz` + ); + + const tarballBuffer = zlib.unzipSync(tarballDownloadBuffer); + + // Extract binary from package and write to disk + fs.writeFileSync( + fallbackBinaryPath, + extractFileFromTarball(tarballBuffer, `package/bin/${binaryName}`), + { mode: 0o755 } // Make binary file executable + ); +} + +function isPlatformSpecificPackageInstalled() { + try { + // Resolving will fail if the optionalDependency was not installed + require.resolve(`${platformSpecificPackageName}/bin/${binaryName}`); + return true; + } catch (e) { + return false; + } +} + +if (!platformSpecificPackageName) { + throw new Error("Platform not supported!"); +} + +// Skip downloading the binary if it was already installed via optionalDependencies +if (!isPlatformSpecificPackageInstalled()) { + console.log( + "Platform specific package not found. Will manually download binary." + ); + downloadBinaryFromNpm(); +} else { + console.log( + "Platform specific package already installed. Will fall back to manually downloading binary." + ); +} diff --git a/src/main.go b/src/main.go index 8c12282..5107110 100644 --- a/src/main.go +++ b/src/main.go @@ -140,24 +140,3 @@ func main() { exitCode = 1 } } - -// DAVE: verify that the version number is not already in use -// DAVE: find out how to download a binary from an npm package -// DAVE: find out what architectures supported by go and node -// node: -// - os.type() -// - Returns the operating system name as returned by uname(3). -// For example, it returns 'Linux' on Linux, 'Darwin' on macOS, -// and 'Windows_NT' on Windows. -// https://linux.die.net/man/3/uname -// https://en.wikipedia.org/wiki/Uname#Examples -// - os.arch() -// - Possible values are 'arm', 'arm64', 'ia32', 'loong64', 'mips', -// 'mipsel', 'ppc', 'ppc64', 'riscv64', 's390', 's390x', and 'x64'. -// go: -// - $GOOS and $GOARCH -// - valid combinations listed at https://go.dev/doc/install/source#environment -// DAVE: get and save npm creds to 1password -// DAVE: install 1password in github actions and demo it fetching text from 1password -// DAVE: munge package.json to set version number and make it publishable -// DAVE: actually publish the package to npm