feat: Add GitHub deployment artifacts (homebrew, npm) (gt-ufep6)

- Add .goreleaser.yml for multi-platform binary releases
- Add npm-package/ with postinstall binary download
- Add release.yml workflow for GoReleaser + npm publish + Homebrew tap

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-01-02 00:19:26 -08:00
committed by Steve Yegge
parent 8c7ea8a991
commit 03ffefc962
9 changed files with 783 additions and 0 deletions

161
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Install cross-compilation toolchains
run: |
sudo apt-get update
sudo apt-get install -y gcc-mingw-w64-x86-64 gcc-aarch64-linux-gnu
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: >
release --clean
${{ github.repository != 'steveyegge/gastown' && '--skip=publish --skip=announce' || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
runs-on: ubuntu-latest
needs: goreleaser
if: ${{ github.repository == 'steveyegge/gastown' }}
permissions:
contents: read
id-token: write # Required for npm provenance/trusted publishing
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Update npm for OIDC trusted publishing
run: npm install -g npm@latest # Requires npm >= 11.5.1 for trusted publishing
- name: Publish to npm
run: |
cd npm-package
npm publish --access public
# Uses OIDC trusted publishing - no token needed
# Provenance attestations are automatic with trusted publishing
update-homebrew:
runs-on: ubuntu-latest
needs: goreleaser
if: ${{ github.repository == 'steveyegge/gastown' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release info
id: release
run: |
TAG="${GITHUB_REF#refs/tags/}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${TAG#v}" >> $GITHUB_OUTPUT
- name: Download checksums
run: |
curl -sL "https://github.com/steveyegge/gastown/releases/download/${{ steps.release.outputs.tag }}/checksums.txt" -o checksums.txt
- name: Extract checksums
id: checksums
run: |
echo "darwin_amd64=$(grep 'darwin_amd64.tar.gz' checksums.txt | awk '{print $1}')" >> $GITHUB_OUTPUT
echo "darwin_arm64=$(grep 'darwin_arm64.tar.gz' checksums.txt | awk '{print $1}')" >> $GITHUB_OUTPUT
echo "linux_amd64=$(grep 'linux_amd64.tar.gz' checksums.txt | awk '{print $1}')" >> $GITHUB_OUTPUT
echo "linux_arm64=$(grep 'linux_arm64.tar.gz' checksums.txt | awk '{print $1}')" >> $GITHUB_OUTPUT
- name: Update Homebrew formula
run: |
mkdir -p Formula
cat > Formula/gt.rb <<'EOF'
class Gt < Formula
desc "Gas Town CLI - multi-agent workspace manager"
homepage "https://github.com/steveyegge/gastown"
version "${{ steps.release.outputs.version }}"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/steveyegge/gastown/releases/download/v#{version}/gastown_#{version}_darwin_arm64.tar.gz"
sha256 "${{ steps.checksums.outputs.darwin_arm64 }}"
else
url "https://github.com/steveyegge/gastown/releases/download/v#{version}/gastown_#{version}_darwin_amd64.tar.gz"
sha256 "${{ steps.checksums.outputs.darwin_amd64 }}"
end
end
on_linux do
if Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
url "https://github.com/steveyegge/gastown/releases/download/v#{version}/gastown_#{version}_linux_arm64.tar.gz"
sha256 "${{ steps.checksums.outputs.linux_arm64 }}"
else
url "https://github.com/steveyegge/gastown/releases/download/v#{version}/gastown_#{version}_linux_amd64.tar.gz"
sha256 "${{ steps.checksums.outputs.linux_amd64 }}"
end
end
def install
bin.install "gt"
end
test do
system "#{bin}/gt", "version"
end
end
EOF
- name: Push to homebrew-gastown
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$HOMEBREW_TAP_TOKEN" ]; then
echo "::warning::HOMEBREW_TAP_TOKEN not set - skipping Homebrew update"
echo "To enable automatic Homebrew updates:"
echo "1. Create a Personal Access Token with 'repo' scope"
echo "2. Add it as HOMEBREW_TAP_TOKEN in repository secrets"
exit 0
fi
git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/steveyegge/homebrew-gastown.git" tap
cp Formula/gt.rb tap/Formula/gt.rb
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/gt.rb
git commit -m "Update gt to ${{ steps.release.outputs.version }}"
git push

180
.goreleaser.yml Normal file
View File

@@ -0,0 +1,180 @@
# GoReleaser configuration for Gas Town (gt)
# See https://goreleaser.com for documentation
version: 2
before:
hooks:
# Ensure dependencies are up to date
- go mod tidy
builds:
- id: gt-linux-amd64
main: ./cmd/gt
binary: gt
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
ldflags:
- -s -w
- -X github.com/steveyegge/gastown/internal/cmd.Version={{.Version}}
- -X github.com/steveyegge/gastown/internal/cmd.Build={{.ShortCommit}}
- -X github.com/steveyegge/gastown/internal/cmd.Commit={{.Commit}}
- -X github.com/steveyegge/gastown/internal/cmd.Branch={{.Branch}}
- id: gt-linux-arm64
main: ./cmd/gt
binary: gt
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
goos:
- linux
goarch:
- arm64
ldflags:
- -s -w
- -X github.com/steveyegge/gastown/internal/cmd.Version={{.Version}}
- -X github.com/steveyegge/gastown/internal/cmd.Build={{.ShortCommit}}
- -X github.com/steveyegge/gastown/internal/cmd.Commit={{.Commit}}
- -X github.com/steveyegge/gastown/internal/cmd.Branch={{.Branch}}
- id: gt-darwin-amd64
main: ./cmd/gt
binary: gt
env:
- CGO_ENABLED=1
goos:
- darwin
goarch:
- amd64
ldflags:
- -s -w
- -X github.com/steveyegge/gastown/internal/cmd.Version={{.Version}}
- -X github.com/steveyegge/gastown/internal/cmd.Build={{.ShortCommit}}
- -X github.com/steveyegge/gastown/internal/cmd.Commit={{.Commit}}
- -X github.com/steveyegge/gastown/internal/cmd.Branch={{.Branch}}
- id: gt-darwin-arm64
main: ./cmd/gt
binary: gt
env:
- CGO_ENABLED=1
goos:
- darwin
goarch:
- arm64
ldflags:
- -s -w
- -X github.com/steveyegge/gastown/internal/cmd.Version={{.Version}}
- -X github.com/steveyegge/gastown/internal/cmd.Build={{.ShortCommit}}
- -X github.com/steveyegge/gastown/internal/cmd.Commit={{.Commit}}
- -X github.com/steveyegge/gastown/internal/cmd.Branch={{.Branch}}
- id: gt-windows-amd64
main: ./cmd/gt
binary: gt
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
goos:
- windows
goarch:
- amd64
ldflags:
- -s -w
- -X github.com/steveyegge/gastown/internal/cmd.Version={{.Version}}
- -X github.com/steveyegge/gastown/internal/cmd.Build={{.ShortCommit}}
- -X github.com/steveyegge/gastown/internal/cmd.Commit={{.Commit}}
- -X github.com/steveyegge/gastown/internal/cmd.Branch={{.Branch}}
- -buildmode=exe
- id: gt-freebsd-amd64
main: ./cmd/gt
binary: gt
env:
- CGO_ENABLED=0
goos:
- freebsd
goarch:
- amd64
ldflags:
- -s -w
- -X github.com/steveyegge/gastown/internal/cmd.Version={{.Version}}
- -X github.com/steveyegge/gastown/internal/cmd.Build={{.ShortCommit}}
- -X github.com/steveyegge/gastown/internal/cmd.Commit={{.Commit}}
- -X github.com/steveyegge/gastown/internal/cmd.Branch={{.Branch}}
archives:
- id: gt-archive
format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
files:
- LICENSE
- README.md
checksum:
name_template: "checksums.txt"
algorithm: sha256
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "Merge pull request"
- "Merge branch"
groups:
- title: "Features"
regexp: '^.*feat(\(\w+\))?:.*$'
order: 0
- title: "Bug Fixes"
regexp: '^.*fix(\(\w+\))?:.*$'
order: 1
- title: "Others"
order: 999
release:
github:
owner: steveyegge
name: gastown
draft: false
prerelease: auto
name_template: "v{{.Version}}"
header: |
## Gas Town v{{.Version}}
Pre-compiled binaries for Linux, macOS (Intel & Apple Silicon), and Windows.
### Installation
**Homebrew (macOS/Linux):**
```bash
brew install steveyegge/gastown/gt
```
**npm (Node.js):**
```bash
npm install -g @gastown/gt
```
**Manual Install:**
Download the appropriate binary for your platform below, extract it, and place it in your PATH.
# Announce the release
announce:
skip: false

12
npm-package/.npmignore Normal file
View File

@@ -0,0 +1,12 @@
# Ignore test files
test/
# Ignore development files
*.md
!README.md
# Ignore any local binaries (downloaded at install time)
bin/gt
bin/gt.exe
bin/*.tar.gz
bin/*.zip

21
npm-package/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Steve Yegge
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

42
npm-package/README.md Normal file
View File

@@ -0,0 +1,42 @@
# @gastown/gt
Gas Town CLI - multi-agent workspace manager for coordinating AI coding agents.
## Installation
```bash
npm install -g @gastown/gt
```
This will download the appropriate native binary for your platform during installation.
## Usage
```bash
# Check version
gt version
# Initialize a new town
gt init
# View status
gt status
# List rigs
gt rigs
```
## Supported Platforms
- macOS (Intel and Apple Silicon)
- Linux (x64 and ARM64)
- Windows (x64)
## Manual Installation
If npm installation fails, you can download binaries directly from:
https://github.com/steveyegge/gastown/releases
## License
MIT

54
npm-package/bin/gt.js Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const path = require('path');
const os = require('os');
const fs = require('fs');
// Determine the platform-specific binary name
function getBinaryPath() {
const platform = os.platform();
const arch = os.arch();
let binaryName = 'gt';
if (platform === 'win32') {
binaryName = 'gt.exe';
}
// Binary is stored in the package's bin directory
const binaryPath = path.join(__dirname, binaryName);
if (!fs.existsSync(binaryPath)) {
console.error(`Error: gt binary not found at ${binaryPath}`);
console.error('This may indicate that the postinstall script failed to download the binary.');
console.error(`Platform: ${platform}, Architecture: ${arch}`);
process.exit(1);
}
return binaryPath;
}
// Execute the native binary with all arguments passed through
function main() {
const binaryPath = getBinaryPath();
// Spawn the native gt binary with all command-line arguments
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: 'inherit',
env: process.env
});
child.on('error', (err) => {
console.error(`Error executing gt binary: ${err.message}`);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
process.exit(1);
}
process.exit(code || 0);
});
}
main();

51
npm-package/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "@gastown/gt",
"version": "0.1.0",
"description": "Gas Town CLI - multi-agent workspace manager with native binary support",
"main": "bin/gt.js",
"bin": {
"gt": "bin/gt.js"
},
"scripts": {
"postinstall": "node scripts/postinstall.js",
"test": "node scripts/test.js"
},
"keywords": [
"multi-agent",
"workspace-manager",
"ai-agent",
"coding-agent",
"claude",
"git",
"orchestration"
],
"author": "Steve Yegge",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/steveyegge/gastown.git",
"directory": "npm-package"
},
"bugs": {
"url": "https://github.com/steveyegge/gastown/issues"
},
"homepage": "https://github.com/steveyegge/gastown#readme",
"engines": {
"node": ">=14.0.0"
},
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"x64",
"arm64"
],
"files": [
"bin/",
"scripts/",
"README.md",
"LICENSE"
]
}

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env node
const https = require('https');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
// Get package version to determine which release to download
const packageJson = require('../package.json');
const VERSION = packageJson.version;
// Determine platform and architecture
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
let platformName;
let archName;
let binaryName = 'gt';
// Map Node.js platform names to GitHub release names
switch (platform) {
case 'darwin':
platformName = 'darwin';
break;
case 'linux':
platformName = 'linux';
break;
case 'win32':
platformName = 'windows';
binaryName = 'gt.exe';
break;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
// Map Node.js arch names to GitHub release names
switch (arch) {
case 'x64':
archName = 'amd64';
break;
case 'arm64':
archName = 'arm64';
break;
default:
throw new Error(`Unsupported architecture: ${arch}`);
}
return { platformName, archName, binaryName };
}
// Download file from URL
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
console.log(`Downloading from: ${url}`);
const file = fs.createWriteStream(dest);
const request = https.get(url, (response) => {
// Handle redirects
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
console.log(`Following redirect to: ${redirectUrl}`);
downloadFile(redirectUrl, dest).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
return;
}
response.pipe(file);
file.on('finish', () => {
// Wait for file.close() to complete before resolving
// This is critical on Windows where the file may still be locked
file.close((err) => {
if (err) reject(err);
else resolve();
});
});
});
request.on('error', (err) => {
fs.unlink(dest, () => {});
reject(err);
});
file.on('error', (err) => {
fs.unlink(dest, () => {});
reject(err);
});
});
}
// Extract tar.gz file
function extractTarGz(tarGzPath, destDir, binaryName) {
console.log(`Extracting ${tarGzPath}...`);
try {
// Use tar command to extract
execSync(`tar -xzf "${tarGzPath}" -C "${destDir}"`, { stdio: 'inherit' });
// The binary should now be in destDir
const extractedBinary = path.join(destDir, binaryName);
if (!fs.existsSync(extractedBinary)) {
throw new Error(`Binary not found after extraction: ${extractedBinary}`);
}
// Make executable on Unix-like systems
if (os.platform() !== 'win32') {
fs.chmodSync(extractedBinary, 0o755);
}
console.log(`Binary extracted to: ${extractedBinary}`);
} catch (err) {
throw new Error(`Failed to extract archive: ${err.message}`);
}
}
// Extract zip file (for Windows)
function extractZip(zipPath, destDir, binaryName) {
console.log(`Extracting ${zipPath}...`);
try {
// Use unzip command or powershell on Windows
if (os.platform() === 'win32') {
execSync(`powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`, { stdio: 'inherit' });
} else {
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
}
// The binary should now be in destDir
const extractedBinary = path.join(destDir, binaryName);
if (!fs.existsSync(extractedBinary)) {
throw new Error(`Binary not found after extraction: ${extractedBinary}`);
}
console.log(`Binary extracted to: ${extractedBinary}`);
} catch (err) {
throw new Error(`Failed to extract archive: ${err.message}`);
}
}
// Main installation function
async function install() {
try {
const { platformName, archName, binaryName } = getPlatformInfo();
console.log(`Installing gt v${VERSION} for ${platformName}-${archName}...`);
// Construct download URL
// Format: https://github.com/steveyegge/gastown/releases/download/v0.1.0/gastown_0.1.0_darwin_amd64.tar.gz
const releaseVersion = VERSION;
const archiveExt = platformName === 'windows' ? 'zip' : 'tar.gz';
const archiveName = `gastown_${releaseVersion}_${platformName}_${archName}.${archiveExt}`;
const downloadUrl = `https://github.com/steveyegge/gastown/releases/download/v${releaseVersion}/${archiveName}`;
// Determine destination paths
const binDir = path.join(__dirname, '..', 'bin');
const archivePath = path.join(binDir, archiveName);
const binaryPath = path.join(binDir, binaryName);
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true });
}
// Download the archive
console.log(`Downloading gt binary...`);
await downloadFile(downloadUrl, archivePath);
// Extract the archive based on platform
if (platformName === 'windows') {
extractZip(archivePath, binDir, binaryName);
} else {
extractTarGz(archivePath, binDir, binaryName);
}
// Clean up archive
fs.unlinkSync(archivePath);
// Verify the binary works
try {
const output = execSync(`"${binaryPath}" version`, { encoding: 'utf8' });
console.log(`gt installed successfully: ${output.trim()}`);
} catch (err) {
console.warn('Warning: Could not verify binary version');
}
} catch (err) {
console.error(`Error installing gt: ${err.message}`);
console.error('');
console.error('Installation failed. You can try:');
console.error('1. Installing manually from: https://github.com/steveyegge/gastown/releases');
console.error('2. Opening an issue: https://github.com/steveyegge/gastown/issues');
process.exit(1);
}
}
// Run installation if not in CI environment
if (!process.env.CI) {
install();
} else {
console.log('Skipping binary download in CI environment');
}

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
console.log('Running gt npm package tests...\n');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`[PASS] ${name}`);
passed++;
} catch (err) {
console.log(`[FAIL] ${name}: ${err.message}`);
failed++;
}
}
// Test 1: Check binary exists
test('Binary exists in bin directory', () => {
const binaryName = os.platform() === 'win32' ? 'gt.exe' : 'gt';
const binaryPath = path.join(__dirname, '..', 'bin', binaryName);
if (!fs.existsSync(binaryPath)) {
throw new Error(`Binary not found at ${binaryPath}`);
}
});
// Test 2: Binary is executable (version check)
test('Binary executes and returns version', () => {
const binaryName = os.platform() === 'win32' ? 'gt.exe' : 'gt';
const binaryPath = path.join(__dirname, '..', 'bin', binaryName);
const output = execSync(`"${binaryPath}" version`, { encoding: 'utf8' });
if (!output.includes('gt version')) {
throw new Error(`Unexpected version output: ${output}`);
}
});
// Test 3: Wrapper script exists
test('Wrapper script (gt.js) exists', () => {
const wrapperPath = path.join(__dirname, '..', 'bin', 'gt.js');
if (!fs.existsSync(wrapperPath)) {
throw new Error(`Wrapper not found at ${wrapperPath}`);
}
});
// Summary
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);