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
83 changes: 50 additions & 33 deletions .github/workflows/release-fork.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
# LeXwDeX/opencode fork. Stripped-down version of upstream
# publish.yml — removes repo guard, Blacksmith runners, code
# signing, npm publish, and Tauri desktop.
# Trigger : Manual workflow_dispatch (PRIMARY) — pick branch + version
# Trigger : Manual workflow_dispatch (PRIMARY) — pick main or dev
# Push to main/dev ONLY registers the workflow in the Actions UI
# (the build job is gated on `github.event_name == 'workflow_dispatch'`)
# Inputs : version — release version string (e.g. "1.14.30-fork.2")
# Leave blank for auto timestamped version.
# create_release — when true, create a GitHub Release with the
# Version : Independent GraphAgent SemVer derived only from graphagent-v* tags.
# dev builds the next stable version as X.Y.Z-dev.N; main publishes
# X.Y.Z and marks it Latest. OpenCode package versions are ignored.
# Inputs : create_release — when true, create a GitHub Release with the
# built binaries + SHA256SUMS attached. Builds
# from any branch other than `main` are marked
# prerelease (test build); `main` = formal release.
# from `dev` are marked prerelease (test build);
# `main` builds are formal and become Latest.
# platforms — comma-separated subset of linux,macos,windows
# to build. Leave blank to build all three.
# Jobs : package-templates — package latest reference templates from the
Expand All @@ -31,15 +32,11 @@ on:
# Manual trigger — the primary way to build releases
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 1.14.30-fork.2). Leave blank for auto."
required: false
type: string
create_release:
description: "Create a GitHub Release with the built binaries"
required: false
type: boolean
default: false
default: true
platforms:
description: "Platforms to build, comma-separated (linux,macos,windows). Leave blank for all."
required: false
Expand All @@ -58,7 +55,36 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: false

concurrency:
group: release-fork
cancel-in-progress: false

jobs:
version:
name: Resolve GraphAgent Version
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.release-version.outputs.channel }}
version: ${{ steps.release-version.outputs.version }}
tag: ${{ steps.release-version.outputs.tag }}
prerelease: ${{ steps.release-version.outputs.prerelease }}
latest: ${{ steps.release-version.outputs.latest }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Bun
uses: ./.github/actions/setup-bun
with:
save-cache: false

- name: Resolve Version
id: release-version
run: bun run ./packages/opencode/script/release-version.ts

# Package the latest reference templates from the dedicated config repo
# (LeXwDeX/opencode-dag-config) into a release asset. dev/main do not manage
# these templates anymore — the config repo is the single source of truth.
Expand Down Expand Up @@ -98,7 +124,7 @@ jobs:

build-cli:
name: Build CLI (${{ matrix.name }})
needs: package-templates
needs: [version, package-templates]
# Skip the actual build when triggered by push (registration-only). Per-
# matrix-entry platform filtering can't live here — `matrix` isn't in
# scope for a job-level `if:` — so it's applied to each step below instead.
Expand Down Expand Up @@ -182,8 +208,8 @@ jobs:
if: inputs.platforms == '' || contains(inputs.platforms, matrix.name)
run: ./packages/opencode/script/build.ts --single --skip-install
env:
OPENCODE_CHANNEL: latest
OPENCODE_VERSION: ${{ inputs.version || '' }}
OPENCODE_CHANNEL: ${{ needs.version.outputs.channel == 'main' && 'latest' || 'dev' }}
OPENCODE_VERSION: ${{ needs.version.outputs.version }}

# Bundle ripgrep into each dist/opencode-*/bin so air-gapped users do not
# hit the runtime download in packages/core/src/ripgrep/binary.ts.
Expand Down Expand Up @@ -221,7 +247,7 @@ jobs:

release:
name: Create GitHub Release
needs: [build-cli, package-templates]
needs: [version, build-cli, package-templates]
if: inputs.create_release
runs-on: ubuntu-latest
steps:
Expand All @@ -234,16 +260,6 @@ jobs:
path: release-assets
merge-multiple: true

- name: Determine Version Tag
id: tag
run: |
VER="${{ inputs.version }}"
if [ -z "$VER" ]; then
VER="0.0.0-$(date +%Y%m%d%H%M)"
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "tag=v$VER" >> "$GITHUB_OUTPUT"

- name: Generate SHA256SUMS
working-directory: release-assets
run: |
Expand All @@ -254,17 +270,18 @@ jobs:
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
# Releases built from any branch other than `main` (e.g. `dev`) are
# test builds and get marked --prerelease; only `main` produces a
# formal release.
# dev releases are prereleases and can never become Latest. main
# releases are formal and are explicitly promoted to Latest.
run: |
EXTRA_FLAGS=()
if [ "${{ github.ref_name }}" != "main" ]; then
EXTRA_FLAGS+=(--prerelease)
if [ "${{ needs.version.outputs.channel }}" = "main" ]; then
EXTRA_FLAGS+=(--latest)
else
EXTRA_FLAGS+=(--prerelease --latest=false)
fi
gh release create "${{ steps.tag.outputs.tag }}" \
--title "opencode ${{ steps.tag.outputs.version }}" \
--notes "Fork release from branch ${{ github.ref_name }}" \
gh release create "${{ needs.version.outputs.tag }}" \
--title "OpenCode GraphAgent v${{ needs.version.outputs.version }}" \
--notes "GraphAgent release from branch ${{ github.ref_name }}" \
--target "${{ github.sha }}" \
"${EXTRA_FLAGS[@]}" \
release-assets/*
Expand Down
116 changes: 116 additions & 0 deletions packages/opencode/script/release-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
const tagPrefix = "graphagent-v"
const stableTag = /^graphagent-v(\d+)\.(\d+)\.(\d+)$/
const devTag = /^graphagent-v(\d+)\.(\d+)\.(\d+)-dev\.(\d+)$/

type Version = readonly [major: number, minor: number, patch: number]

export function resolveReleaseVersion(input: { branch: string; tags: string[] }) {
const channel = input.branch
if (channel !== "main" && channel !== "dev") {
throw new Error(`GraphAgent releases require main or dev, received: ${channel || "(empty)"}`)
}

const latest = input.tags
.flatMap((tag) => {
const version = parseStableTag(tag)
return version ? [version] : []
})
.toSorted(compareVersion)
.at(-1)
const target = nextVersion(latest)
const base = target.join(".")

if (channel === "main") {
return {
channel,
version: base,
tag: `${tagPrefix}${base}`,
prerelease: false,
latest: true,
}
}

const sequence =
Math.max(
0,
...input.tags.flatMap((tag) => {
const parsed = parseDevTag(tag)
return parsed && sameVersion(parsed.version, target) ? [parsed.sequence] : []
}),
) + 1
const version = `${base}-dev.${sequence}`
return {
channel,
version,
tag: `${tagPrefix}${version}`,
prerelease: true,
latest: false,
}
}

function parseStableTag(tag: string): Version | undefined {
const match = stableTag.exec(tag)
if (!match) return undefined
return [Number(match[1]), Number(match[2]), Number(match[3])]
}

function parseDevTag(tag: string) {
const match = devTag.exec(tag)
if (!match) return undefined
return {
version: [Number(match[1]), Number(match[2]), Number(match[3])] as Version,
sequence: Number(match[4]),
}
}

function nextVersion(version: Version | undefined): Version {
if (!version) return [1, 0, 0]
return [version[0], version[1], version[2] + 1]
}

function compareVersion(left: Version, right: Version) {
return left[0] - right[0] || left[1] - right[1] || left[2] - right[2]
}

function sameVersion(left: Version, right: Version) {
return left[0] === right[0] && left[1] === right[1] && left[2] === right[2]
}

async function readTags() {
const child = Bun.spawn(["git", "tag", "--list", `${tagPrefix}*`], {
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
if (exitCode !== 0) throw new Error(`Unable to list GraphAgent tags: ${stderr.trim()}`)
return stdout.split(/\r?\n/).filter(Boolean)
}

async function main() {
const output = process.env.GITHUB_OUTPUT
if (!output) throw new Error("GITHUB_OUTPUT is required")

const release = resolveReleaseVersion({
branch: process.env.GITHUB_REF_NAME ?? "",
tags: await readTags(),
})
const { appendFile } = await import("node:fs/promises")
await appendFile(
output,
[
`channel=${release.channel}`,
`version=${release.version}`,
`tag=${release.tag}`,
`prerelease=${release.prerelease}`,
`latest=${release.latest}`,
"",
].join("\n"),
)
console.log(`GraphAgent release: ${release.tag} (channel=${release.channel}, latest=${release.latest})`)
}

if (import.meta.main) await main()
66 changes: 66 additions & 0 deletions packages/opencode/test/release-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { resolveReleaseVersion } from "../script/release-version"

describe("GraphAgent release versions", () => {
test("starts the independent main release line at 1.0.0", () => {
expect(resolveReleaseVersion({ branch: "main", tags: [] })).toEqual({
channel: "main",
version: "1.0.0",
tag: "graphagent-v1.0.0",
prerelease: false,
latest: true,
})
})

test("starts dev at 1.0.0-dev.1 and ignores inherited OpenCode tags", () => {
expect(
resolveReleaseVersion({
branch: "dev",
tags: ["v1.17.11-main.10", "v0.0.0-202608082302"],
}),
).toEqual({
channel: "dev",
version: "1.0.0-dev.1",
tag: "graphagent-v1.0.0-dev.1",
prerelease: true,
latest: false,
})
})

test("increments the dev sequence within the pending stable version", () => {
expect(
resolveReleaseVersion({
branch: "dev",
tags: ["graphagent-v1.0.0-dev.1", "graphagent-v1.0.0-dev.3"],
}).version,
).toBe("1.0.0-dev.4")
})

test("promotes the pending dev version on main and moves dev to the next patch", () => {
expect(resolveReleaseVersion({ branch: "main", tags: ["graphagent-v1.0.0-dev.4"] }).version).toBe("1.0.0")
expect(resolveReleaseVersion({ branch: "dev", tags: ["graphagent-v1.0.0"] }).version).toBe("1.0.1-dev.1")
expect(
resolveReleaseVersion({
branch: "main",
tags: ["graphagent-v1.0.0", "graphagent-v1.0.1-dev.2"],
}).version,
).toBe("1.0.1")
})

test("rejects releases from feature branches", () => {
expect(() => resolveReleaseVersion({ branch: "feat/example", tags: [] })).toThrow(
"GraphAgent releases require main or dev",
)
})

test("wires one resolved version into both the build and GitHub Release", async () => {
const workflow = await Bun.file(new URL("../../../.github/workflows/release-fork.yml", import.meta.url)).text()

expect(workflow).not.toContain("inputs.version")
expect(workflow).not.toContain("0.0.0-")
expect(workflow).toContain("OPENCODE_CHANNEL: ${{ needs.version.outputs.channel == 'main' && 'latest' || 'dev' }}")
expect(workflow).toContain("OPENCODE_VERSION: ${{ needs.version.outputs.version }}")
expect(workflow).toContain('gh release create "${{ needs.version.outputs.tag }}"')
expect(workflow).toContain("--prerelease --latest=false")
})
})
Loading