release-fork #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |