diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index a4cc07dd4..49381ddbd 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -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 @@ -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 @@ -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. @@ -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. @@ -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. @@ -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: @@ -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: | @@ -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/* diff --git a/packages/opencode/script/release-version.ts b/packages/opencode/script/release-version.ts new file mode 100644 index 000000000..5f8cddecb --- /dev/null +++ b/packages/opencode/script/release-version.ts @@ -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() diff --git a/packages/opencode/test/release-version.test.ts b/packages/opencode/test/release-version.test.ts new file mode 100644 index 000000000..e0542d9ad --- /dev/null +++ b/packages/opencode/test/release-version.test.ts @@ -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") + }) +})