Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: publish
run-name: "${{ format('release {0}', inputs.bump) }}"

on:
workflow_dispatch:
inputs:
bump:
description: "Bump major, minor, or patch"
required: true
type: choice
options:
- patch
- minor
- major
version:
description: "Override version (optional)"
required: false
type: string

concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}

permissions:
id-token: write
contents: write
packages: write

jobs:
publish:
runs-on: ubuntu-latest
if: github.repository == 'votsuk/opendocker'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- run: git fetch --force --tags

- uses: oven-sh/setup-bun@v2

- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"

- name: Setup Git Identity
run: |
git config --global user.email "kustovsteven@gmail.com"
git config --global user.name "opendocker"

- name: Install Dependencies
run: bun install

- name: Publish
id: publish
run: ./script/publish-start.ts
env:
OPENDOCKER_BUMP: ${{ inputs.bump }}
OPENDOCKER_VERSION: ${{ inputs.version }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- uses: actions/upload-artifact@v4
with:
name: opendocker-binaries
path: |
packages/cli/dist/*.tar.gz
packages/cli/dist/*.zip

outputs:
release: ${{ steps.publish.outputs.release }}
tag: ${{ steps.publish.outputs.tag }}
version: ${{ steps.publish.outputs.version }}

publish-release:
needs: publish
if: needs.publish.outputs.tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.publish.outputs.tag }}

- uses: oven-sh/setup-bun@v2

- name: Install Dependencies
run: bun install

- name: Undraft Release
run: ./script/publish-complete.ts
env:
OPENDOCKER_VERSION: ${{ needs.publish.outputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"name": "opendocker",
"description": "A CLI tool for managing Docker",
"type": "module",
"private": true,
"bin": {
"opendocker": "bin/opendocker"
},
"files": [
"bin/",
"package.json",
Expand Down
67 changes: 67 additions & 0 deletions packages/cli/scripts/publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bun

import { $ } from "bun"
import path from "path"
import fs from "fs"
import { fileURLToPath } from "url"

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")

process.chdir(dir)

const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8"))
const version = pkg.version

console.log(`Publishing opendocker@${version}`)

// Get all platform packages from dist/
const distDir = path.join(dir, "dist")
const platformDirs = fs.readdirSync(distDir).filter((name) => {
const fullPath = path.join(distDir, name)
const stat = fs.statSync(fullPath)
return stat.isDirectory() && name.startsWith("opendocker-")
})

console.log(`Found ${platformDirs.length} platform packages`)

// Update and publish platform-specific packages
for (const platformName of platformDirs) {
const platformDir = path.join(distDir, platformName)
const platformPkgPath = path.join(platformDir, "package.json")

const platformPkg = JSON.parse(fs.readFileSync(platformPkgPath, "utf-8"))
platformPkg.version = version
fs.writeFileSync(platformPkgPath, JSON.stringify(platformPkg, null, 2) + "\n")

console.log(`Publishing ${platformName}@${version}...`)

try {
await $`npm publish --access public`.cwd(platformDir)
console.log(` ${platformName}`)
} catch (error) {
const errorMessage = String(error)
if (errorMessage.includes("403") || errorMessage.includes("cannot publish over")) {
console.log(` ${platformName} (already published)`)
} else {
throw error
}
}
}

// Publish main package
console.log(`\nPublishing opendocker@${version}...`)
try {
await $`npm publish --access public`.cwd(dir)
console.log(`opendocker@${version}`)
} catch (error) {
const errorMessage = String(error)
if (errorMessage.includes("403") || errorMessage.includes("cannot publish over")) {
console.log(`opendocker@${version} (already published)`)
} else {
throw error
}
}

console.log(`\nPublish complete!`)
34 changes: 34 additions & 0 deletions packages/script/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { $ } from "bun"

const env = {
OPENDOCKER_BUMP: process.env["OPENDOCKER_BUMP"],
OPENDOCKER_VERSION: process.env["OPENDOCKER_VERSION"],
}

const VERSION = await (async () => {
if (env.OPENDOCKER_VERSION) return env.OPENDOCKER_VERSION

// Fetch current version from npm
const version = await fetch("https://registry.npmjs.org/opendocker/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
.catch(() => "0.0.0") // First publish

const [major, minor, patch] = version.split(".").map((x: string) => Number(x) || 0)
const bump = env.OPENDOCKER_BUMP?.toLowerCase()

if (bump === "major") return `${major + 1}.0.0`
if (bump === "minor") return `${major}.${minor + 1}.0`
return `${major}.${minor}.${patch + 1}`
})()

export const Script = {
get version() {
return VERSION
},
}

console.log(`opendocker script`, JSON.stringify(Script, null, 2))
142 changes: 142 additions & 0 deletions script/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env bun

import { $ } from "bun"

const team = ["opendocker", "votsuk"] // Add your team GitHub usernames

export async function getLatestRelease(): Promise<string> {
return fetch("https://api.github.com/repos/votsuk/opendocker/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.tag_name.replace(/^v/, ""))
.catch(() => "0.0.0")
}

type Commit = {
hash: string
author: string | null
message: string
}

export async function getCommits(from: string, to: string): Promise<Commit[]> {
const fromRef = from === "0.0.0" ? "" : from.startsWith("v") ? from : `v${from}`
const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`

let compare = ""
try {
if (fromRef) {
compare = await $`gh api "/repos/votsuk/opendocker/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
} else {
// First release - get all commits
compare = await $`gh api "/repos/votsuk/opendocker/commits?per_page=100" --jq '.[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
}
} catch {
console.log("Could not fetch commits from GitHub API")
return []
}

const commits: Commit[] = []

for (const line of compare.split("\n").filter(Boolean)) {
try {
const data = JSON.parse(line) as { sha: string; login: string | null; message: string }
const message = data.message.split("\n")[0] ?? ""

// Skip certain commit types
if (message.match(/^(release:|chore:|ci:|test:)/i)) continue

commits.push({
hash: data.sha.slice(0, 7),
author: data.login,
message,
})
} catch {
// Skip malformed JSON lines
}
}

return commits
}

async function summarizeWithOpenCode(commits: Commit[]): Promise<string[]> {
const apiKey = process.env.OPENCODE_API_KEY
if (!apiKey) {
console.log("No OPENCODE_API_KEY, using raw commit messages")
return commits.map((c) => {
const attribution = c.author && !team.includes(c.author) ? ` (@${c.author})` : ""
return `- ${c.message}${attribution}`
})
}

console.log("Summarizing commits with OpenCode...")

// Use OpenCode API to summarize commits
const response = await fetch("https://api.opencode.ai/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
messages: [
{
role: "user",
content: `Summarize these git commits for a changelog. Return a markdown bullet list with concise, user-friendly descriptions. Group similar changes. Do not include commit hashes. Start each line with "- ".

Commits:
${commits.map((c) => `- ${c.message}`).join("\n")}`,
},
],
}),
})

if (!response.ok) {
console.log("OpenCode API error, using raw commits")
return commits.map((c) => `- ${c.message}`)
}

const data = (await response.json()) as any
const content = data.choices?.[0]?.message?.content ?? ""

// Extract bullet points from response
const lines = content
.split("\n")
.filter((l: string) => l.trim().startsWith("-"))
.map((l: string) => l.trim())

// Add attributions for external contributors
const contributors = [...new Set(commits.filter((c) => c.author && !team.includes(c.author)).map((c) => c.author))]

if (contributors.length > 0) {
lines.push("")
lines.push(`**Contributors:** ${contributors.map((c) => `@${c}`).join(", ")}`)
}

return lines
}

export async function buildNotes(from: string, to: string): Promise<string[]> {
const commits = await getCommits(from, to)

if (commits.length === 0) {
return ["No notable changes"]
}

console.log(`Generating changelog for ${commits.length} commits since v${from}`)
return summarizeWithOpenCode(commits)
}

// CLI entrypoint
if (import.meta.main) {
const from = process.argv[2] || (await getLatestRelease())
const to = process.argv[3] || "HEAD"

console.log(`Generating changelog: v${from} -> ${to}\n`)

const notes = await buildNotes(from, to)
console.log("\n=== Changelog ===")
console.log(notes.join("\n"))
}
8 changes: 8 additions & 0 deletions script/publish-complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env bun

import { $ } from "bun"
import { Script } from "../packages/script/src/index.ts"

console.log(`Undrafting release v${Script.version}...`)
await $`gh release edit v${Script.version} --draft=false`
console.log(`Release v${Script.version} is now public!`)
Loading