Skip to content

release-fork

release-fork #7

Workflow file for this run

# FORK-ONLY FILE — not present upstream, so it never conflicts on rebase.
#
# Publishes this fork's binaries to its own GitHub Releases. Upstream's publish.yml is left completely
# untouched: all four of its jobs are guarded by `if: github.repository == 'anomalyco/opencode'`, so in
# a fork it already does nothing. Nothing here runs script/publish.ts either — that script publishes to
# npm/brew/AUR/ghcr and, more importantly, commits and force-pushes to `dev`, which would fight the
# rebase workflow this fork depends on. Version comes from OPENCODE_VERSION instead, so releasing
# creates no commits at all.
#
# Two invariants this file exists to protect, both of which silently break `opencode upgrade`:
#
# 1. The release must end up neither draft nor prerelease. `api.github.com/.../releases/latest` —
# which both installation/index.ts and the install script read — returns "the most recent
# non-prerelease, non-draft release". A release left in either state is invisible to every client.
# 2. OPENCODE_CHANNEL must be `latest`. Anything else makes database.ts pick the filename
# `opencode-<channel>.db`, so the build starts against an empty database and every existing
# project and session disappears.
#
# The release is created as a draft and only published once all builds succeeded, so a failed build
# cannot leave `/releases/latest` pointing at a release with missing assets.
name: release-fork
on:
workflow_dispatch:
inputs:
base:
description: "Upstream version to base this release on (blank = nearest upstream tag)"
required: false
type: string
permissions:
contents: write
concurrency:
group: release-fork
cancel-in-progress: false
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.compute.outputs.version }}
tag: ${{ steps.compute.outputs.tag }}
sha: ${{ steps.compute.outputs.sha }}
bun: ${{ steps.bun.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
# Full history and tags: the base version is derived from the nearest upstream tag.
fetch-depth: 0
- id: bun
# Read the bun version from package.json rather than pinning it here. packages/script
# validates the running bun against `packageManager` and throws on mismatch, so a hardcoded
# version here would break every build the day upstream bumps it.
run: echo "version=$(node -p "require('./package.json').packageManager.split('@')[1]")" >> "$GITHUB_OUTPUT"
- id: compute
run: |
set -euo pipefail
BASE='${{ inputs.base }}'
if [ -z "$BASE" ]; then
# Read the base from packages/opencode/package.json, not from `git describe`.
#
# Upstream's release tags are NOT ancestors of dev: script/publish.ts commits and tags on a
# detached commit, then pushes a separate "sync release versions" commit to dev. So
# `git describe` cannot see v1.18.16 and walks back to whatever ancient tag happens to be
# reachable — the first run of this workflow derived 1.4.11 that way.
#
# That same sync commit is what writes the released version into every package.json, so this
# file on dev is upstream's own record of which release the branch corresponds to.
BASE=$(node -p "require('./packages/opencode/package.json').version")
fi
case "$BASE" in
[0-9]*.[0-9]*.[0-9]*) ;;
*)
echo "::error::Base version '$BASE' is not a semver release. Pass one via the 'base' input."
exit 1
;;
esac
# Timestamp sorts lexicographically, which is what semver prerelease comparison does with
# alphanumeric identifiers; the sha makes the build traceable to exact source.
VERSION="${BASE}-jdscript.$(date -u +%Y%m%d%H%M)-$(git rev-parse --short=7 HEAD)"
# The upstream commit this build sits on. `jdscript` is a linear series of fork patches on top
# of an upstream commit, so its merge-base with upstream's dev IS that commit.
#
# Recorded because the version's upstream component does not identify the upstream tree:
# upstream lands many commits without bumping it (15 in a single day, all still 1.18.16), so
# two releases can both say 1.18.16 while sitting on trees that differ by a fortnight of work.
#
# Fetched from upstream directly, NOT from this repo's `dev` mirror. Using the mirror looks
# equivalent and is not: if `dev` was fast-forwarded locally but not pushed, the merge-base is
# the mirror's stale tip and this silently records the wrong base — the exact failure this
# field exists to prevent. Against real upstream it is correct by construction.
UPSTREAM=unknown
if git fetch --no-tags --quiet https://github.com/anomalyco/opencode.git dev 2>/dev/null; then
UPSTREAM=$(git merge-base HEAD FETCH_HEAD 2>/dev/null || echo unknown)
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "base=$BASE" >> "$GITHUB_OUTPUT"
echo "upstream=$UPSTREAM" >> "$GITHUB_OUTPUT"
echo "Releasing v$VERSION (base $BASE, upstream $UPSTREAM)"
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Both coordinates, because neither alone identifies the build: the upstream version is
# ambiguous (see the compute step), and the fork commit stops being an ancestor of `jdscript`
# the next time that branch is rebased. Creating this release also creates its tag, which is an
# independent ref — no rebase or force-push can move it, and GitHub never collects a tagged
# commit. That tag, not the branch, is what makes a release traceable forever.
#
# printf rather than a heredoc so the notes cannot inherit this file's YAML indentation.
notes=$(printf '%s\n' \
"Fork build of upstream opencode." \
"" \
"- Upstream version: \`${{ steps.compute.outputs.base }}\`" \
"- Upstream commit: \`${{ steps.compute.outputs.upstream }}\`" \
"- Fork commit: \`${{ steps.compute.outputs.sha }}\`" \
"" \
"Exact source for this build: \`git checkout ${{ steps.compute.outputs.tag }}\`")
# Draft on purpose — see the header. Published by the `publish` job once builds pass.
gh release create '${{ steps.compute.outputs.tag }}' \
--draft \
--target '${{ steps.compute.outputs.sha }}' \
--title '${{ steps.compute.outputs.tag }}' \
--notes "$notes"
build:
needs: version
strategy:
fail-fast: false
matrix:
include:
- runner: macos-latest
target: darwin-arm64
- runner: ubuntu-latest
target: linux-x64
# GitHub-hosted ARM Linux runner. Free on public repositories only — drop this entry if the
# fork is private and a linux-arm64 binary is not needed.
- runner: ubuntu-24.04-arm
target: linux-arm64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
# The tag does not exist yet: a draft release only records its target commit. Check out that
# commit directly so every matrix job builds identical source.
ref: ${{ needs.version.outputs.sha }}
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ needs.version.outputs.bun }}
- run: bun install
- name: Build ${{ matrix.target }}
working-directory: packages/opencode
env:
# Highest-priority version source in packages/script, so no package.json is rewritten and no
# release commit is produced.
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
# MUST be `latest` — see the header.
OPENCODE_CHANNEL: latest
#
# OPENCODE_RELEASE is deliberately NOT set. It is the only thing gating build.ts's archive and
# upload block (script/build.ts:235), and that block cannot work with `--single`:
#
# gh release upload ./dist/*.zip ./dist/*.tar.gz
#
# A single-target build produces one archive, so one of those globs matches nothing — and
# bun's shell fails on an unmatched glob rather than passing it through. Upstream never hits
# this because it always builds all twelve targets, so both extensions always exist.
#
# Archiving and uploading therefore happen in the next step instead, which keeps build.ts —
# upstream's own release script — untouched. `Script.release` gates nothing else, so the
# binary produced here is identical.
# `--single` builds only the runner's own native target, and skips the baseline and musl
# variants. That is how the target list is limited to three without touching build.ts.
run: bun run ./script/build.ts --single
- name: Archive and upload ${{ matrix.target }}
working-directory: packages/opencode
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Same layout and names build.ts would produce, because the install script derives the
# download filename from them: opencode-<os>-<arch>.tar.gz on linux, .zip elsewhere.
for dir in dist/*/; do
name=$(basename "$dir")
case "$name" in
*linux*) (cd "dist/$name/bin" && tar -czf "../../$name.tar.gz" *) ;;
*) (cd "dist/$name/bin" && zip -qr "../../$name.zip" *) ;;
esac
done
# nullglob so the extension this target did not produce expands to nothing instead of being
# passed through as a literal path — the exact failure this step exists to avoid.
shopt -s nullglob
assets=(dist/*.tar.gz dist/*.zip)
if [ ${#assets[@]} -eq 0 ]; then
echo "::error::No archives were produced for ${{ matrix.target }}."
exit 1
fi
printf 'uploading %s\n' "${assets[@]}"
gh release upload '${{ needs.version.outputs.tag }}' "${assets[@]}" --clobber --repo '${{ github.repository }}'
publish:
needs: [version, build]
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.version.outputs.tag }}
steps:
- name: Publish release
run: gh release edit "$TAG" --draft=false --latest --repo '${{ github.repository }}'
- name: Verify the release is actually reachable by opencode upgrade
run: |
set -euo pipefail
# Assert the two header invariants against the live API rather than trusting the flags above.
STATE=$(gh release view "$TAG" --repo '${{ github.repository }}' --json isDraft,isPrerelease,assets)
echo "$STATE"
if [ "$(jq -r '.isDraft' <<<"$STATE")" != "false" ]; then
echo "::error::Release is still a draft; /releases/latest will not return it."
exit 1
fi
if [ "$(jq -r '.isPrerelease' <<<"$STATE")" != "false" ]; then
echo "::error::Release is marked prerelease; /releases/latest will not return it."
exit 1
fi
COUNT=$(jq -r '[.assets[] | select(.name | test("^opencode-"))] | length' <<<"$STATE")
if [ "$COUNT" -lt 3 ]; then
echo "::error::Expected 3 binary assets, found $COUNT."
exit 1
fi
# What every client actually reads. If this does not match, upgrade will never see the build.
LATEST=$(gh api 'repos/${{ github.repository }}/releases/latest' --jq '.tag_name')
echo "releases/latest → $LATEST"
if [ "$LATEST" != "$TAG" ]; then
echo "::error::releases/latest is $LATEST, expected $TAG."
exit 1
fi