From 037463e43b41fc0e98a85acc7a00047c69277a16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:12:02 +0000 Subject: [PATCH 1/4] Add a CircleCI tag-triggered release pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors .github/workflows/release.yml: a v* tag builds one compiled binary per target on a runner of that target's own architecture, then publishes them as a GitHub Release with per-binary SHA-256 checksums. Covers four of the project's six targets. CircleCI removed every Intel macOS resource in June 2024 and offers no Windows arm64 resource class, and cross-compilation is impossible because @opentui/core's native half is a per-platform optional dependency — so bun-darwin-x64 and bun-windows-arm64 have no CircleCI-hosted runner of the right architecture. The publish gate expects four binaries and four checksums accordingly, so a partial matrix can never publish a release that silently omits a platform. An opt-in build-darwin-x64-rosetta job sketches building the Intel macOS target under Rosetta 2 on an Apple silicon runner. It is deliberately left out of the workflow and marked unverified: the chain it depends on (Rosetta present, Bun's installer selecting by uname -m, process.arch then reporting x64 so @opentui/core-darwin-x64 links) is plausible but untested, and there is no macOS host here to test it on. CIRCLE_TAG reaches the publish script through the environment rather than shell interpolation. A tag is attacker-influenceable by anyone who can push one, and v$(id) is a valid Git ref name, so direct substitution would execute it under a token holding contents: write. Every job carries both the tags and branches filters. CircleCI ignores tag pushes for any job that does not opt in, so omitting either on even one job — publish included — silently skips the whole workflow. Requires a GITHUB_TOKEN project environment variable; there is no ambient token as there is in GitHub Actions. macOS and Windows executors need a paid plan. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- .circleci/config.yml | 302 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..e63d75a --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,302 @@ +# CircleCI release pipeline for tecode (Issue: tag-triggered binary release). +# +# Mirrors `.github/workflows/release.yml`: pushing a `v*` tag builds one +# compiled binary per target on a runner of that target's OWN platform, then +# publishes them as a GitHub Release with per-binary SHA-256 checksums. +# +# ## Why one job per target, and not a build matrix on one machine +# +# `@opentui/core` ships six platform-native optional dependencies +# (`@opentui/core-{darwin,linux,win32}-{x64,arm64}`), each gated by `os`/`cpu` +# in its package.json. `bun install` links only the one matching the HOST, and +# `@opentui/core` resolves its native half through a template-string dynamic +# import that Bun's bundler cannot statically resolve for a foreign platform. +# Cross-compilation therefore fails outright — see `scripts/release.ts`'s +# "Why this machine cannot produce all six binaries". Each target must be +# built on its own architecture. +# +# ## What CircleCI can and cannot build +# +# CircleCI covers four of the project's six targets: +# +# bun-darwin-arm64 macos executor (Apple silicon) +# bun-linux-x64 machine executor, x86 resource class +# bun-linux-arm64 machine executor, arm resource class +# bun-windows-x64 windows executor +# +# The other two have no CircleCI-hosted runner of the right architecture: +# +# bun-darwin-x64 CircleCI removed ALL Intel macOS resources in June 2024; +# only Apple silicon remains. See the opt-in +# `build-darwin-x64-rosetta` job below for an UNVERIFIED +# workaround. +# bun-windows-arm64 CircleCI offers no Windows arm64 resource class. +# +# Keep `.github/workflows/release.yml` for those two, or drop the platforms — +# whichever you decide, `PUBLISH_EXPECTED_BINARIES` below must match, or the +# publish job will refuse to release. +# +# ## Required project configuration +# +# - `GITHUB_TOKEN` — an environment variable in CircleCI project settings, +# holding a token with `contents: write` on this repository. Unlike GitHub +# Actions there is no ambient `github.token`; nothing here can publish +# without it. +# - macOS and Windows executors require a paid CircleCI plan. + +version: 2.1 + +# How many binaries (and therefore how many .sha256 files) the publish job +# requires before it will create a release. Four, because that is what the +# workflow below actually builds — raise it if you add the two missing +# targets back via self-hosted runners. +# +# This gate exists so a partial matrix can never publish a release that +# silently omits a platform: a user downloading it would find their build +# missing with no indication anything went wrong. + +executors: + linux-x64: + machine: + image: ubuntu-2404:current + resource_class: medium + linux-arm64: + machine: + image: ubuntu-2404:current + resource_class: arm.medium + macos-arm64: + macos: + xcode: "16.4.0" + resource_class: m4pro.medium + windows-x64: + machine: + image: windows-server-2022-gui:current + shell: bash.exe + resource_class: windows.medium + +commands: + install-bun-posix: + description: Install Bun on Linux/macOS and put it on PATH for later steps. + steps: + - run: + name: Install Bun + command: | + set -euo pipefail + curl -fsSL https://bun.sh/install | bash + echo 'export PATH="$HOME/.bun/bin:$PATH"' >> "$BASH_ENV" + - run: + name: Report the Bun and host architecture actually in use + command: | + set -euo pipefail + bun --version + uname -m + + build-and-persist: + description: >- + Build one target with the repo's own release script, then hand the + binary and its checksum to the workspace for the publish job. + parameters: + target: + type: string + steps: + - run: + name: Install dependencies + # --frozen-lockfile so a runner can never silently resolve a + # different dependency tree than the one committed. bun.lock records + # all six @opentui/core platform packages, so this succeeds on every + # platform even though only one of them links here. + command: bun install --frozen-lockfile + - run: + name: Build << parameters.target >> + # release.ts enforces the size limit and writes the .sha256 itself. + command: bun run release << parameters.target >> + - persist_to_workspace: + root: . + paths: + - dist + +jobs: + build-linux-x64: + executor: linux-x64 + steps: + - checkout + - install-bun-posix + - build-and-persist: + target: bun-linux-x64 + + build-linux-arm64: + executor: linux-arm64 + steps: + - checkout + - install-bun-posix + - build-and-persist: + target: bun-linux-arm64 + + build-darwin-arm64: + executor: macos-arm64 + steps: + - checkout + - install-bun-posix + - build-and-persist: + target: bun-darwin-arm64 + + build-windows-x64: + executor: windows-x64 + steps: + - checkout + - run: + name: Install Bun + shell: powershell.exe + command: | + irm bun.sh/install.ps1 | iex + - run: + name: Build bun-windows-x64 + # The Bun installer puts bun.exe under %USERPROFILE%\.bun\bin, which + # is not on PATH in this shell yet. + command: | + set -euo pipefail + export PATH="$USERPROFILE/.bun/bin:$PATH" + bun --version + bun install --frozen-lockfile + bun run release bun-windows-x64 + - persist_to_workspace: + root: . + paths: + - dist + + # OPT-IN AND UNVERIFIED — not part of the workflow below. + # + # CircleCI has no Intel macOS runner, but an Apple silicon runner can run + # x86_64 processes under Rosetta 2. Running the Bun installer inside + # `arch -x86_64` should fetch the x64 build of Bun, whose `process.arch` + # then reports "x64", which should in turn make `bun install` link + # `@opentui/core-darwin-x64` and let `--target=bun-darwin-x64` resolve. + # + # That chain is plausible but has NOT been tested — it depends on Rosetta 2 + # being present on the image and on Bun's installer selecting by `uname -m`. + # Add this job to the workflow (with the same tag filters) to try it, and + # verify the resulting binary actually runs on an Intel Mac before trusting + # it. Do not assume it works because it is written here. + build-darwin-x64-rosetta: + executor: macos-arm64 + steps: + - checkout + - run: + name: Install the x86_64 Bun under Rosetta 2 + command: | + set -euo pipefail + arch -x86_64 /bin/bash -c 'curl -fsSL https://bun.sh/install | bash' + echo 'export PATH="$HOME/.bun/bin:$PATH"' >> "$BASH_ENV" + - run: + name: Build bun-darwin-x64 under Rosetta 2 + command: | + set -euo pipefail + arch -x86_64 /bin/bash -lc ' + set -euo pipefail + uname -m # expect x86_64, not arm64 + bun --version + node -e "console.log(process.arch)" 2>/dev/null || true + bun install --frozen-lockfile + bun run release bun-darwin-x64 + ' + - persist_to_workspace: + root: . + paths: + - dist + + publish: + docker: + - image: cimg/base:current + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Refuse to publish unless every expected artifact is present + # A partial matrix must never produce a release that silently omits + # a platform — someone downloading it would find their build missing + # with nothing to say anything went wrong. + environment: + PUBLISH_EXPECTED_BINARIES: "4" + command: | + set -euo pipefail + ls -la dist/ + BIN_COUNT=$(find dist -maxdepth 1 -type f -name 'tecode-*' ! -name '*.sha256' | wc -l | tr -d ' ') + SHA_COUNT=$(find dist -maxdepth 1 -type f -name '*.sha256' | wc -l | tr -d ' ') + echo "release: found $BIN_COUNT binaries and $SHA_COUNT checksums" + if [ "$BIN_COUNT" -ne "$PUBLISH_EXPECTED_BINARIES" ] || [ "$SHA_COUNT" -ne "$PUBLISH_EXPECTED_BINARIES" ]; then + echo "release: refusing to publish — expected $PUBLISH_EXPECTED_BINARIES of each, got $BIN_COUNT and $SHA_COUNT" >&2 + exit 1 + fi + - run: + name: Create the GitHub Release and upload every asset + # CIRCLE_TAG is passed through the environment rather than + # interpolated into the script: it is attacker-influenceable by + # anyone who can push a tag, and `v$(id)` is a valid Git ref name, + # so a directly-substituted tag would be executed by the shell under + # a token that can write repository contents. + command: | + set -euo pipefail + + : "${GITHUB_TOKEN:?set GITHUB_TOKEN in CircleCI project settings (needs contents: write)}" + : "${CIRCLE_TAG:?this job only runs for tag pushes}" + + api="https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" + + notes=$(cat docs/release-notes-template.md) + payload=$( + TAG="$CIRCLE_TAG" NOTES="$notes" python3 -c ' + import json, os + print(json.dumps({ + "tag_name": os.environ["TAG"], + "name": "tecode " + os.environ["TAG"], + "body": os.environ["NOTES"], + }))' + ) + + upload_url=$( + curl -fsSL -X POST "$api/releases" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -d "$payload" | + python3 -c 'import json,sys; print(json.load(sys.stdin)["upload_url"].split("{")[0])' + ) + + for asset in dist/tecode-*; do + name=$(basename "$asset") + echo "release: uploading $name" + curl -fsSL -X POST "$upload_url?name=$name" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$asset" > /dev/null + done + + echo "release: published $CIRCLE_TAG" + +workflows: + release: + # Every job needs BOTH filters. CircleCI ignores tag pushes unless a job + # opts in with `tags`, and without `branches: ignore` the same jobs would + # also run on every ordinary branch push. Omitting either on ANY job — + # including `publish` — silently breaks the pipeline: a job without them + # never runs for the tag, and the whole workflow is then skipped. + jobs: + - build-linux-x64: + filters: &release-tags + tags: + only: /^v.*/ + branches: + ignore: /.*/ + - build-linux-arm64: + filters: *release-tags + - build-darwin-arm64: + filters: *release-tags + - build-windows-x64: + filters: *release-tags + - publish: + filters: *release-tags + requires: + - build-linux-x64 + - build-linux-arm64 + - build-darwin-arm64 + - build-windows-x64 From 25eca1505f244f5a97da589877d5daa8cf1ca3ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:53:24 +0000 Subject: [PATCH 2/4] Drop unbuildable release targets, move release pipeline to CircleCI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CircleCI removed all Intel-macOS hosted resources in June 2024 and has never offered a Windows-arm64 resource class, and the project has no Intel Mac or Windows-on-Arm hardware to self-host either on — so bun-darwin-x64 and bun-windows-arm64 are dropped from RELEASE_TARGETS, not deferred. The remaining four targets build on CircleCI: two hosted Linux executors, plus self-hosted resource-class placeholders for the owner's own Apple Silicon Mac and in-house Windows x64 box (the opt-in, unverified Rosetta job is removed along with them). .github/workflows/release.yml is deleted outright rather than left alongside .circleci/config.yml: both fire on the same v* tag push and would race to create the same GitHub Release. scripts/release.test.ts pins the real 4-target list (not just a count) and adds a new invariant test asserting .circleci/config.yml's PUBLISH_EXPECTED_BINARIES equals RELEASE_TARGETS.length, parsed via Bun.YAML rather than regexed, so the two can never silently drift. Updates requirements.md (Req 13.2), design.md §17, README.md, docs/manual-release-verification.md, docs/release-notes-template.md, and tasks.md to match, and points Intel-Mac/Windows-on-Arm users at running from source since no binary is published for either. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- .circleci/config.yml | 168 ++++++++++------- .github/workflows/release.yml | 283 ---------------------------- README.md | 87 +++++---- design.md | 2 +- docs/manual-release-verification.md | 49 +++-- docs/release-notes-template.md | 10 +- requirements.md | 2 +- scripts/release.test.ts | 88 +++++++-- scripts/release.ts | 77 ++++++-- tasks.md | 4 +- 10 files changed, 317 insertions(+), 453 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index e63d75a..5442a19 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,8 +1,11 @@ # CircleCI release pipeline for tecode (Issue: tag-triggered binary release). # -# Mirrors `.github/workflows/release.yml`: pushing a `v*` tag builds one -# compiled binary per target on a runner of that target's OWN platform, then -# publishes them as a GitHub Release with per-binary SHA-256 checksums. +# Pushing a `v*` tag builds one compiled binary per target on a runner of +# that target's OWN platform, then publishes them as a GitHub Release with +# per-binary SHA-256 checksums. This is the ONLY release pipeline for this +# repo — `.github/workflows/release.yml` has been deleted; keeping both +# would let a single tag push race two pipelines to create the same GitHub +# Release. # # ## Why one job per target, and not a build matrix on one machine # @@ -12,29 +15,57 @@ # `@opentui/core` resolves its native half through a template-string dynamic # import that Bun's bundler cannot statically resolve for a foreign platform. # Cross-compilation therefore fails outright — see `scripts/release.ts`'s -# "Why this machine cannot produce all six binaries". Each target must be -# built on its own architecture. +# "Why this machine cannot produce even the four remaining binaries". Each +# target must be built on its own architecture. # -# ## What CircleCI can and cannot build +# ## Only four targets are published — two are gone for good, not deferred # -# CircleCI covers four of the project's six targets: +# `RELEASE_TARGETS` in `scripts/release.ts` ships exactly these four: # -# bun-darwin-arm64 macos executor (Apple silicon) -# bun-linux-x64 machine executor, x86 resource class -# bun-linux-arm64 machine executor, arm resource class -# bun-windows-x64 windows executor +# bun-darwin-arm64 self-hosted runner, the owner's own Apple silicon Mac +# bun-linux-x64 CircleCI-hosted machine executor +# bun-linux-arm64 CircleCI-hosted machine executor, arm.medium +# bun-windows-x64 self-hosted runner, the owner's own Windows x64 box # -# The other two have no CircleCI-hosted runner of the right architecture: +# `bun-darwin-x64` (Intel macOS) and `bun-windows-arm64` (Windows on Arm) are +# NOT built by anything, anywhere, in this pipeline: # -# bun-darwin-x64 CircleCI removed ALL Intel macOS resources in June 2024; -# only Apple silicon remains. See the opt-in -# `build-darwin-x64-rosetta` job below for an UNVERIFIED -# workaround. -# bun-windows-arm64 CircleCI offers no Windows arm64 resource class. +# - CircleCI removed every Intel-macOS resource class in June 2024 — its +# hosted `macos` executor is Apple silicon only, and the owner has no +# Intel Mac to self-host one on either. +# - CircleCI offers no Windows-arm64 resource class at all, hosted or +# self-hosted, and the owner has no Windows-on-Arm device. # -# Keep `.github/workflows/release.yml` for those two, or drop the platforms — -# whichever you decide, `PUBLISH_EXPECTED_BINARIES` below must match, or the -# publish job will refuse to release. +# Both were considered and ruled out, not overlooked — see `scripts/ +# release.ts`'s TSDoc, "Two targets were dropped, not just left off this +# machine", for the full reasoning. A user on either platform has no binary +# to download and is pointed at the README's "From source" instructions +# (`bun run packages/cli/src/main.ts`) instead. +# +# `PUBLISH_EXPECTED_BINARIES` below is 4 for exactly this reason — it MUST +# always equal `RELEASE_TARGETS.length` (`scripts/release.test.ts` pins this +# invariant and fails the build if the two drift apart), so if a target is +# ever added back (new CircleCI resource class, or new owned hardware), both +# this file and `scripts/release.ts` need updating together. +# +# ## Self-hosted runners: what to substitute, and how to register one +# +# The `macos-arm64-self-hosted` and `windows-x64-self-hosted` executors below +# are PLACEHOLDERS naming a `/` pair that does not +# exist until you create it. For EACH of the owner's two machines (the Apple +# Silicon Mac and the in-house Windows x64 box): +# +# 1. `circleci runner resource-class create / "" --generate-token` +# (run once per machine, from a machine with the CircleCI CLI installed +# and authenticated — this is a CircleCI *project/org* namespace you +# choose, not a hostname). This prints a runner AUTH TOKEN — copy it, +# it is shown only once. +# 2. Install "machine runner 3" on that physical machine (CircleCI's +# installer for macOS/Windows), configuring it with the resource class +# name and auth token from step 1. The runner then polls CircleCI for +# jobs targeting that resource class and executes them locally. +# 3. Replace `/macos-arm64` and `/windows-x64` below +# with the REAL `/` pairs step 1 created. # # ## Required project configuration # @@ -42,7 +73,8 @@ # holding a token with `contents: write` on this repository. Unlike GitHub # Actions there is no ambient `github.token`; nothing here can publish # without it. -# - macOS and Windows executors require a paid CircleCI plan. +# - The self-hosted runner auth tokens from step 1 above, configured on each +# physical machine's runner install — not stored in this repo. version: 2.1 @@ -64,15 +96,41 @@ executors: machine: image: ubuntu-2404:current resource_class: arm.medium - macos-arm64: - macos: - xcode: "16.4.0" - resource_class: m4pro.medium - windows-x64: - machine: - image: windows-server-2022-gui:current - shell: bash.exe - resource_class: windows.medium + # SELF-HOSTED — the owner's own Apple Silicon Mac, not a CircleCI-hosted + # image. `/macos-arm64` is a PLACEHOLDER: replace it with the + # real `/` pair created by running (once, from a machine + # with the CircleCI CLI authenticated against this project): + # + # circleci runner resource-class create /macos-arm64 \ + # "tecode release: owner's Apple Silicon Mac" --generate-token + # + # then install "machine runner 3" on that Mac using the resource class name + # and the auth token the command above prints (shown once — save it). A + # self-hosted machine executor is declared with `machine: true` (no + # `image:` — there is nothing to select an image FROM, it's a specific + # physical machine) plus `resource_class:` naming the runner registered + # above; it is NOT the hosted `macos:` executor type. + macos-arm64-self-hosted: + machine: true + resource_class: "/macos-arm64" + # SELF-HOSTED — the owner's own in-house Windows x64 machine. Same + # placeholder convention as above: replace `/windows-x64` with + # the real pair from + # + # circleci runner resource-class create /windows-x64 \ + # "tecode release: owner's Windows x64 box" --generate-token + # + # then install machine runner 3 on that Windows machine with the resulting + # resource class name and auth token. `shell: bash.exe` matches this + # config's own `build-windows-x64` job, which writes POSIX-shell `run` + # steps (`set -euo pipefail`, `export PATH=...`) throughout except where a + # step explicitly opts into `shell: powershell.exe` — a self-hosted runner + # needs Git for Windows (or another `bash.exe` provider) on PATH for this + # default to resolve. + windows-x64-self-hosted: + machine: true + resource_class: "/windows-x64" + shell: bash.exe commands: install-bun-posix: @@ -133,7 +191,7 @@ jobs: target: bun-linux-arm64 build-darwin-arm64: - executor: macos-arm64 + executor: macos-arm64-self-hosted steps: - checkout - install-bun-posix @@ -141,7 +199,7 @@ jobs: target: bun-darwin-arm64 build-windows-x64: - executor: windows-x64 + executor: windows-x64-self-hosted steps: - checkout - run: @@ -164,46 +222,6 @@ jobs: paths: - dist - # OPT-IN AND UNVERIFIED — not part of the workflow below. - # - # CircleCI has no Intel macOS runner, but an Apple silicon runner can run - # x86_64 processes under Rosetta 2. Running the Bun installer inside - # `arch -x86_64` should fetch the x64 build of Bun, whose `process.arch` - # then reports "x64", which should in turn make `bun install` link - # `@opentui/core-darwin-x64` and let `--target=bun-darwin-x64` resolve. - # - # That chain is plausible but has NOT been tested — it depends on Rosetta 2 - # being present on the image and on Bun's installer selecting by `uname -m`. - # Add this job to the workflow (with the same tag filters) to try it, and - # verify the resulting binary actually runs on an Intel Mac before trusting - # it. Do not assume it works because it is written here. - build-darwin-x64-rosetta: - executor: macos-arm64 - steps: - - checkout - - run: - name: Install the x86_64 Bun under Rosetta 2 - command: | - set -euo pipefail - arch -x86_64 /bin/bash -c 'curl -fsSL https://bun.sh/install | bash' - echo 'export PATH="$HOME/.bun/bin:$PATH"' >> "$BASH_ENV" - - run: - name: Build bun-darwin-x64 under Rosetta 2 - command: | - set -euo pipefail - arch -x86_64 /bin/bash -lc ' - set -euo pipefail - uname -m # expect x86_64, not arm64 - bun --version - node -e "console.log(process.arch)" 2>/dev/null || true - bun install --frozen-lockfile - bun run release bun-darwin-x64 - ' - - persist_to_workspace: - root: . - paths: - - dist - publish: docker: - image: cimg/base:current @@ -216,6 +234,12 @@ jobs: # A partial matrix must never produce a release that silently omits # a platform — someone downloading it would find their build missing # with nothing to say anything went wrong. + # + # MUST always equal `RELEASE_TARGETS.length` in `scripts/ + # release.ts` (currently 4) — `scripts/release.test.ts` parses + # this exact YAML value and asserts that equality, precisely so + # the two numbers cannot silently drift apart. Change this the + # same commit you change `RELEASE_TARGETS`, or that test fails. environment: PUBLISH_EXPECTED_BINARIES: "4" command: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7825449..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,283 +0,0 @@ -name: Release - -# Issue #36 "4.5 CI pipeline" gate 6 (Req 8.5, 13.2; design.md §17): the -# tag-triggered six-target compiled-binary matrix, driving `scripts/ -# release.ts` (Issue #35) rather than reimplementing any of its logic — -# the matrix, `binaryFileName`, the size assertion, and the target-name -# filter (`bun run release `) all already live there; read that -# file's TSDoc before changing anything here. -# -# ## Why six runners, not three -# -# It is tempting to "simplify" this to three OS-only runners -# (ubuntu-latest/macos-latest/windows-latest) and pass `--target=` twice -# per runner for the two arches. THAT DOES NOT WORK, and the failure mode -# if someone tries it is actively misleading: `@opentui/core` ships six -# platform-*and-arch*-specific optional dependencies -# (`@opentui/core-{darwin,linux,win32}-{x64,arm64}`, one per target, each -# gated by `os`/`cpu` package.json fields), and `bun install` links only -# the ONE matching the runner it's actually running on. `@opentui/core`'s -# own runtime resolves its native half with a template-string dynamic -# import (`import(\`@opentui/core-${process.platform}-${process.arch}/ -# index.ts\`)`) that Bun's bundler can only ever resolve for the HOST's own -# linked package — so `ubuntu-latest` (x64) can build `bun-linux-x64` but -# NOT `bun-linux-arm64`, `macos-latest` (arm64) can build -# `bun-darwin-arm64` but NOT `bun-darwin-x64`, and so on for Windows. The -# resulting error (`Could not resolve: "@opentui/core--/ -# index.ts"`) reads like a missing dependency, not an architecture -# mismatch — `scripts/release.ts`'s own `classifyBuildFailure` recognizes -# this exact signature and labels it a known limitation for exactly this -# reason (see that script's top-of-file TSDoc, "Why this machine cannot -# produce all six binaries"). -# -# The fix is one runner per target, matched on BOTH os and arch, which is -# what the matrix below does. This repo is public, so GitHub's free arm64 -# hosted runners (`ubuntu-24.04-arm`, `windows-11-arm`) and the free -# Intel/Apple-Silicon macOS runners (`macos-15-intel`/`macos-latest`) are all -# available with no billing implications: -# -# | target | runner | -# |-------------------|--------------------| -# | bun-linux-x64 | ubuntu-latest | -# | bun-linux-arm64 | ubuntu-24.04-arm | -# | bun-darwin-x64 | macos-15-intel | -# | bun-darwin-arm64 | macos-latest | -# | bun-windows-x64 | windows-latest | -# | bun-windows-arm64 | windows-11-arm | -# -# darwin/x64 needs its own pinned label because macos-14 and later — -# including `macos-latest` — run on Apple Silicon/arm64. `macos-15-intel` -# is the label to use: the older `macos-13` image was RETIRED on -# 2025-12-04, so pinning it makes this matrix leg unschedulable and -# silently drops the darwin/x64 binary and its size report. Note -# `macos-15-intel` is GitHub's LAST x86_64 Actions image and is announced -# only through August 2027; when it goes, `bun-darwin-x64` has no hosted -# runner left and will need a self-hosted one (or dropping). Re-verify all six labels against GitHub's current hosted-runner -# docs before ever changing this table; runner labels get renamed/retired, -# and a stale one fails the whole matrix job with an opaque "no runner -# matching the specified labels" error that looks nothing like the -# `@opentui/core` failure above. - -on: - push: - tags: - - "v*" - -# Read-only at the WORKFLOW level, deliberately (narrowed in Issue #36's -# PR #76): `build` and `summary` only build binaries and upload them as -# Actions artifacts, which `actions/upload-artifact` does through the -# Actions API rather than the contents scope — a write-scoped -# `GITHUB_TOKEN` on either job would be reachable by any compromised -# dependency action for no benefit. `publish` below (Issue #38 "5.2 User -# documentation and release") is the one job that actually creates the -# GitHub Release — it scopes `contents: write` to ITSELF, in its own `jobs. -# publish.permissions` block, exactly as this comment previously said a -# future publishing step should. Do not move that grant back up here: the -# workflow-level default must stay `read` so `build`/`summary` keep the -# narrowest token they can. -permissions: - contents: read - -jobs: - build: - name: Build (${{ matrix.target.bunTarget }} on ${{ matrix.target.runner }}) - strategy: - # One target's failure (a real regression, or a runner-label going - # stale) must never hide the other five targets' results — every - # target's own outcome is what the summary job below reports on. - fail-fast: false - matrix: - target: - - { bunTarget: bun-linux-x64, runner: ubuntu-latest } - - { bunTarget: bun-linux-arm64, runner: ubuntu-24.04-arm } - - { bunTarget: bun-darwin-x64, runner: macos-15-intel } - - { bunTarget: bun-darwin-arm64, runner: macos-latest } - - { bunTarget: bun-windows-x64, runner: windows-latest } - - { bunTarget: bun-windows-arm64, runner: windows-11-arm } - runs-on: ${{ matrix.target.runner }} - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - # `--frozen-lockfile`, same as every CI job: a RELEASE artifact must - # be built from the locked dependency set, and a stale lockfile - # should fail the build loudly rather than silently resolving to - # something else. `bun.lock` already records all six - # `@opentui/core--` optional dependencies, so the - # frozen install still links THIS runner's own native one — which is - # the entire reason the matrix is split by runner in the first place - # (this file's own top-of-file comment). - - run: bun install --frozen-lockfile - # `scripts/release.ts`'s own target-name filter (its TSDoc's - # "Usage") — builds ONLY this runner's target, so a runner never - # attempts (and fails on) one of the other five. This single - # invocation also writes `dist/.sha256` (Issue #38 - # "5.2 User documentation and release", `scripts/release.ts`'s - # `writeChecksumFile`, wired into this script's own `main()` right - # after a successful, in-budget build) — there is no separate - # checksum step here on purpose, so the checksum can never drift from - # what this exact build step produced. - - name: Build ${{ matrix.target.bunTarget }} - run: bun run release ${{ matrix.target.bunTarget }} - - name: Record size for the summary job - shell: bash - run: | - mkdir -p size-report - # Excludes the `.sha256` sibling explicitly — relying on `ls`'s - # lexical sort (a prefix always sorts before anything that - # extends it, so the real binary would win `head -n1` anyway) is - # correct today but fragile to read; this is unambiguous. - BIN=$(ls dist/tecode-* 2>/dev/null | grep -v '\.sha256$' | head -n1) - if [ -z "$BIN" ]; then - echo "release: no binary found in dist/ for ${{ matrix.target.bunTarget }}" >&2 - exit 1 - fi - SIZE_BYTES=$(wc -c < "$BIN" | tr -d ' ') - echo "{\"target\":\"${{ matrix.target.bunTarget }}\",\"file\":\"$(basename "$BIN")\",\"sizeBytes\":$SIZE_BYTES}" > "size-report/${{ matrix.target.bunTarget }}.json" - - uses: actions/upload-artifact@v4 - with: - # Unique per target so all six land as distinct release assets, - # never overwriting each other (`actions/upload-artifact` would - # silently collide on a shared name across matrix jobs). The - # `.sha256` sibling is excluded here (its own `checksum-*` - # artifact below) so `publish`'s later binary/checksum download - # passes stay simple, unambiguous globs rather than needing to - # filter one out of the other. - name: tecode-${{ matrix.target.bunTarget }} - path: | - dist/tecode-* - !dist/*.sha256 - if-no-files-found: error - - uses: actions/upload-artifact@v4 - with: - name: size-report-${{ matrix.target.bunTarget }} - path: size-report/${{ matrix.target.bunTarget }}.json - if-no-files-found: error - - uses: actions/upload-artifact@v4 - with: - # Issue #38 "5.2 User documentation and release": the SHA-256 - # checksum `bun run release`'s own `writeChecksumFile` wrote - # alongside this target's binary — `publish` downloads every - # `checksum-*` artifact and attaches each `.sha256` file to the - # Release next to the binary it describes. - name: checksum-${{ matrix.target.bunTarget }} - path: dist/*.sha256 - if-no-files-found: error - - summary: - name: Release summary - needs: build - # Runs even when one or more matrix legs failed (`fail-fast: false` - # above) — the summary table is exactly where a partial-failure release - # run should be diagnosed from, not skipped. - if: always() - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@v4 - with: - pattern: size-report-* - path: size-reports - merge-multiple: true - - name: Write target x size table to the job summary - shell: bash - run: | - { - echo "## Release matrix — target sizes" - echo "" - echo "| Target | Binary | Size | Limit | Status |" - echo "|---|---|---|---|---|" - } >> "$GITHUB_STEP_SUMMARY" - - LIMIT_BYTES=120000000 - shopt -s nullglob - for f in size-reports/*.json; do - TARGET=$(jq -r '.target' "$f") - FILE=$(jq -r '.file' "$f") - SIZE=$(jq -r '.sizeBytes' "$f") - SIZE_MB=$(awk "BEGIN { printf \"%.2f\", $SIZE / 1000000 }") - LIMIT_MB=$(awk "BEGIN { printf \"%.2f\", $LIMIT_BYTES / 1000000 }") - if [ "$SIZE" -le "$LIMIT_BYTES" ]; then - STATUS="ok" - else - STATUS="OVER LIMIT" - fi - echo "| $TARGET | $FILE | ${SIZE_MB} MB | ${LIMIT_MB} MB | $STATUS |" >> "$GITHUB_STEP_SUMMARY" - done - - MISSING=$((6 - $(ls size-reports/*.json 2>/dev/null | wc -l))) - if [ "$MISSING" -gt 0 ]; then - { - echo "" - echo "**$MISSING of 6 targets did not report a size** — see the \`build\` job's per-target logs (a failed or skipped matrix leg never uploads its size-report artifact)." - } >> "$GITHUB_STEP_SUMMARY" - fi - - # Issue #38 "5.2 User documentation and release": publish the actual - # GitHub Release once every matrix leg has succeeded. `needs: build` with - # no `if: always()` (unlike `summary` above) is the gate that matters - # here — GitHub Actions runs a job that `needs` a matrix job only when - # EVERY instance of that matrix succeeded, so a single failed/skipped - # target (a real regression, or one of the six runner labels going stale - # — this file's own top-of-file comment) silently skips `publish` - # entirely rather than shipping a Release with fewer than six binaries. - # `summary` is deliberately NOT a dependency here: it is purely - # informational (`if: always()`, and can run — and even fail on its own - # unrelated grounds — independently of whether every real binary - # actually built), and gating `publish` on it too would let a `summary`- - # job-only hiccup block a release whose six binaries are otherwise fine. - publish: - name: Publish GitHub Release - needs: build - runs-on: ubuntu-latest - # The ONE job in this workflow that writes repository contents (this - # file's own top-of-file `permissions:` comment) — scoped here, to - # exactly this job, rather than at the workflow level, so the - # `build`/`summary` jobs' tokens stay read-only no matter what. - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Download every target's binary - uses: actions/download-artifact@v4 - with: - pattern: tecode-bun-* - path: release-assets - merge-multiple: true - - name: Download every target's checksum - uses: actions/download-artifact@v4 - with: - pattern: checksum-* - path: release-assets - merge-multiple: true - - name: Confirm all 6 binaries and all 6 checksums are present before publishing anything - shell: bash - run: | - BIN_COUNT=$(ls release-assets/tecode-* 2>/dev/null | grep -v '\.sha256$' | wc -l) - SHA_COUNT=$(ls release-assets/*.sha256 2>/dev/null | wc -l) - echo "release: found $BIN_COUNT binaries and $SHA_COUNT checksums in release-assets/" - ls -la release-assets/ - if [ "$BIN_COUNT" -ne 6 ] || [ "$SHA_COUNT" -ne 6 ]; then - echo "release: refusing to publish — expected 6 binaries and 6 checksums, got $BIN_COUNT and $SHA_COUNT" >&2 - exit 1 - fi - # `${{ ... }}` is substituted into the script text *before* bash parses - # it, and `v$(id)` is a valid Git ref name (`git check-ref-format` - # accepts it), so interpolating `github.ref_name` straight into `run:` - # would let anyone who can push a tag run arbitrary commands under this - # job's `contents: write` token. Passing it through `env:` instead makes - # it a shell *variable*, which bash never re-parses for substitutions. - - name: Create the GitHub Release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} - run: | - gh release create "$RELEASE_TAG" \ - --title "tecode $RELEASE_TAG" \ - --notes-file docs/release-notes-template.md \ - release-assets/* diff --git a/README.md b/README.md index ae57d83..bf982ce 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Issue #36 "4.5 CI pipeline" (Req 13.1, 13.2, 13.4; design.md §15, §16). `.github/workflows/ci.yml` runs five independent jobs on every push to -`main` and every pull request; `.github/workflows/release.yml` runs a -sixth, tag-triggered job (see the "Release" section below). Each CI job -is reproducible locally with one `bun run` script: +`main` and every pull request; a separate, tag-triggered CircleCI pipeline +(`.circleci/config.yml`) handles releases (see the "Release" section +below). Each CI job is reproducible locally with one `bun run` script: | CI job | Local command | What it checks | |---------------|------------------------|-----------------| @@ -49,42 +49,50 @@ this repo's own CI/dev environment doesn't have — see [`docs/manual-release-verification.md`](docs/manual-release-verification.md) for the exact procedure. -Pushing a `v*` tag runs `.github/workflows/release.yml`, which builds all -six `RELEASE_TARGETS` in parallel — one matched-architecture runner per -target (see that workflow's own top-of-file comment for the full "why six -runners, not three" explanation and the exact target→runner table), each -invoking `bun run release ` so it only ever builds the one -target its own `@opentui/core` native optional dependency can actually -link for. Every target uploads its binary as its own artifact -(`tecode-`) and a small size-report JSON; a final `summary` job -(`if: always()`, so it still runs and reports even if one leg failed) -collects those into a target × size Markdown table written to the run's -job summary, checked against `scripts/release.ts`'s own -`SIZE_LIMIT_BYTES` (120,000,000 bytes, the decimal — stricter — reading of -"≤ 120 MB"). +Pushing a `v*` tag runs the CircleCI pipeline in `.circleci/config.yml`, +which builds all four `RELEASE_TARGETS` in parallel — one runner per +target, two of them CircleCI-hosted and two self-hosted on the project +owner's own machines (see that config's own top-of-file comment for the +full explanation and the exact target→runner table), each invoking +`bun run release ` so it only ever builds the one target +its own `@opentui/core` native optional dependency can actually link for. +Every target persists its binary and checksum to a shared workspace for +the `publish` job. + +Only four of the six theoretically possible `darwin`/`linux`/`windows` × +`x64`/`arm64` combinations are published: `bun-darwin-x64` (Intel macOS) +and `bun-windows-arm64` (Windows on Arm) are dropped, not deferred — no CI +runner of either architecture exists (the release provider removed its +Intel-macOS resource class in June 2024 and offers no Windows-arm64 one at +all), and the project owner has neither an Intel Mac nor a Windows-on-Arm +machine to self-host either on. Cross-compiling either from another +platform is impossible for the same `@opentui/core` reason no target can +be cross-compiled at all (`scripts/release.ts`'s TSDoc, "Why this machine +cannot produce even the four remaining binaries"). If you're on one of +the two dropped platforms, see "From source" below — running from source +works today, no release required. Issue #38 "5.2 User documentation and release" adds a SHA-256 checksum next to every binary (`scripts/release.ts`'s `writeChecksumFile`, run as part of the same `bun run release ` invocation — see that -script's TSDoc) and a `publish` job that runs only once all six matrix -legs succeed: it downloads every binary and checksum, refuses to proceed -unless exactly six of each are present, and runs `gh release create` -against the pushed tag with `docs/release-notes-template.md` as the -release body. `publish` is the only job in this workflow with -`contents: write`, scoped to itself in its own `permissions:` block — -`build` and `summary` stay read-only (`.github/workflows/release.yml`'s -top-of-file comment). +script's TSDoc) and a `publish` job that runs only once all four build +jobs succeed: it refuses to proceed unless exactly four binaries and four +checksums are present (`PUBLISH_EXPECTED_BINARIES`, kept equal to +`RELEASE_TARGETS.length` by `scripts/release.test.ts`), then creates the +GitHub Release directly against the GitHub API using a `GITHUB_TOKEN` +configured in CircleCI project settings (CircleCI has no ambient +equivalent of GitHub Actions' `github.token`). ## Install -tecode ships as six self-contained, single-file compiled binaries — one -per `darwin`/`linux`/`windows` × `x64`/`arm64` combination (Req 13.2) — -built by `scripts/release.ts` and published as GitHub Release assets by -`.github/workflows/release.yml`'s tag-triggered `publish` job (see -"Release" above). No separate runtime install is required to RUN a -downloaded binary; all packages in this monorepo are `"private": true`, so -there is no `npm install -g tecode` — a compiled binary or a source -checkout are the only two ways to run it. +tecode ships as four self-contained, single-file compiled binaries — one +per published target in `scripts/release.ts`'s `RELEASE_TARGETS` (Req +13.2) — built and published as GitHub Release assets by the CircleCI +pipeline's tag-triggered `publish` job (see "Release" above). No separate +runtime install is required to RUN a downloaded binary; all packages in +this monorepo are `"private": true`, so there is no `npm install -g +tecode` — a compiled binary or a source checkout are the only two ways to +run it. ### From a published release (once one exists) @@ -93,11 +101,14 @@ checkout are the only two ways to run it. | Platform | Architecture | Asset | |---|---|---| | macOS | Apple Silicon | `tecode-darwin-arm64` | - | macOS | Intel | `tecode-darwin-x64` | | Linux | x64 | `tecode-linux-x64` | | Linux | arm64 | `tecode-linux-arm64` | | Windows | x64 | `tecode-windows-x64.exe` | - | Windows | arm64 | `tecode-windows-arm64.exe` | + + **No binary is published for Intel macOS or Windows on Arm** — see + "Release" above for why. If you're on either platform, use "From + source" below (`bun packages/cli/src/main.ts`): it works on any + platform Bun itself supports, release or no release. 2. Verify the checksum (each binary ships with a `.sha256` sibling asset — `scripts/release.ts`'s `writeChecksumFile`): @@ -143,7 +154,13 @@ currently code-sign or notarize release binaries. 4. To produce your own compiled binary for your own machine's platform: `bun run release ` (e.g. `bun run release bun-linux-x64`) — see the "Release" section above for why this machine can only ever - build ITS OWN host target, not the other five. + build ITS OWN host target, not the other three. This does NOT cover + Intel macOS or Windows on Arm: `RELEASE_TARGETS` no longer names + `bun-darwin-x64`/`bun-windows-arm64` at all (the "Release" section's + dropped-platforms note), so `bun run release` on those two rejects the + target name outright rather than attempting a build — running from + source (step 3 above) is genuinely the only option there, not just the + convenient one. ## Keybindings reference diff --git a/design.md b/design.md index ef8a005..68d0d12 100644 --- a/design.md +++ b/design.md @@ -325,7 +325,7 @@ A core `HostLog` collects structured errors; a `developer.showLog` command dumps ## 17. Build and Distribution - Dev: `bun run packages/cli/src/main.ts `. -- Release: `bun build --compile` per target (darwin/linux/windows × x64/arm64) from `cli`, embedding built-in extension assets (theme JSON, grammar WASM, highlight queries, fallback keymap). A `scripts/release.ts` drives the 6-target matrix and size assertions. +- Release: `bun build --compile` per target from `cli`, embedding built-in extension assets (theme JSON, grammar WASM, highlight queries, fallback keymap). `bun build --compile` can in principle target any darwin/linux/windows × x64/arm64 combination, but `@opentui/core`'s platform-native optional dependencies make cross-compiling a non-host target impossible, so each target is built on a runner of its own platform. A `scripts/release.ts` drives the matrix and size assertions — published on CircleCI, it covers 4 of the 6 combinations (`bun-darwin-arm64`, `bun-linux-x64`, `bun-linux-arm64`, `bun-windows-x64`); `bun-darwin-x64` and `bun-windows-arm64` are dropped for lack of any runner of that architecture (Req 13.2, `scripts/release.ts`'s TSDoc). - Windows note: config path resolution uses `%APPDATA%\tecode\` when `~/.config` is not conventional, exposed uniformly through a `paths` module so the rest of the code never branches on platform. ## 18. Deferred Design Concerns diff --git a/docs/manual-release-verification.md b/docs/manual-release-verification.md index f02bf63..3d75ca0 100644 --- a/docs/manual-release-verification.md +++ b/docs/manual-release-verification.md @@ -24,7 +24,7 @@ specifically-named document a reader can find and update independently of any one source file. `scripts/release.ts`'s own TSDoc points here, and so does `README.md`, so either entry point reaches it. -## 1. The five non-Linux targets +## 1. The three non-Linux-x64 targets **Why this can't run here**: `@opentui/core` ships six platform-specific optional dependencies (`@opentui/core-{darwin,linux,win32}-{x64,arm64}`), @@ -42,15 +42,28 @@ that script recognizes this exact signature and labels it as a known limitation rather than a generic build failure, but it does not — and cannot — work around it. +**Only four targets are published at all**: `scripts/release.ts`'s +`RELEASE_TARGETS` is `bun-darwin-arm64`, `bun-linux-x64`, +`bun-linux-arm64`, and `bun-windows-x64` — NOT the full six-way +`darwin`/`linux`/`windows` × `x64`/`arm64` cross-product. `bun-darwin-x64` +(Intel macOS) and `bun-windows-arm64` (Windows on Arm) are gone from the +matrix entirely: the release pipeline's CI provider has no runner of +either architecture (no Intel-macOS resource class since June 2024, no +Windows-arm64 resource class at all), and the project has no such hardware +to self-host either one. There is nothing to manually verify for those two +— they are not built anywhere, by anyone, as a deliberate decision, not an +oversight (`scripts/release.ts`'s TSDoc, "Two targets were dropped, not +just left off this machine"). This section's procedure therefore covers +the three remaining non-host targets only. + **Procedure** (run once per platform, on a real machine or CI runner of -that platform — this is exactly what Issue #36's tag-triggered release -matrix automates): - -1. On a `darwin`/`x64`, `darwin`/`arm64`, `linux`/`arm64`, `windows`/`x64`, - or `windows`/`arm64` machine with Bun installed, clone the repo and run - `bun install` — this links that host's OWN - `@opentui/core--` package, which is what makes the - build possible at all. +that platform — this is exactly what the tag-triggered CircleCI release +pipeline, `.circleci/config.yml`, automates): + +1. On a `darwin`/`arm64`, `linux`/`arm64`, or `windows`/`x64` machine with + Bun installed, clone the repo and run `bun install` — this links that + host's OWN `@opentui/core--` package, which is what + makes the build possible at all. 2. Run `bun run release bun--` (the exact target name from `scripts/release.ts`'s `RELEASE_TARGETS`, e.g. `bun run release bun-darwin-arm64`). @@ -61,16 +74,14 @@ matrix automates): the script prints) in the PR — the exact command depends on the platform, since `scripts/release.ts`'s `binaryFileName` produces a `.exe` suffix on Windows and nothing else does: - - **macOS/Linux** (`bun-darwin-x64`, `bun-darwin-arm64`, `bun-linux-x64`, - `bun-linux-arm64`): `ls -la dist/tecode--`, e.g. `ls - -la dist/tecode-darwin-arm64`. - - **Windows** (`bun-windows-x64`, `bun-windows-arm64`) — `ls -la` is not - a PowerShell command; use PowerShell's own `Get-Item` instead, and - don't forget the `.exe` suffix `binaryFileName` actually generates for - these two targets: - - x64: `(Get-Item .\dist\tecode-windows-x64.exe).Length` - - arm64: `(Get-Item .\dist\tecode-windows-arm64.exe).Length` -4. Repeat for each of the remaining four targets. + - **macOS/Linux** (`bun-darwin-arm64`, `bun-linux-arm64`): + `ls -la dist/tecode--`, e.g. `ls -la + dist/tecode-darwin-arm64`. + - **Windows** (`bun-windows-x64`) — `ls -la` is not a PowerShell + command; use PowerShell's own `Get-Item` instead, and don't forget + the `.exe` suffix `binaryFileName` actually generates for this + target: `(Get-Item .\dist\tecode-windows-x64.exe).Length` +4. Repeat for each of the remaining two targets. ## 2. Windows `%APPDATA%\tecode\` resolution on real Windows diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md index 1d62720..b197ec5 100644 --- a/docs/release-notes-template.md +++ b/docs/release-notes-template.md @@ -1,16 +1,18 @@ Self-contained, single-file compiled binaries — no Bun, Node, or any other -runtime needed on the machine that runs them (Req 13.2). Six targets: -darwin/linux/windows × x64/arm64. +runtime needed on the machine that runs them (Req 13.2). Four targets: +`bun-darwin-arm64`, `bun-linux-x64`, `bun-linux-arm64`, `bun-windows-x64`. +No binary is published for Intel macOS or Windows on Arm — no CI runner of +either architecture exists, and cross-compiling them is not possible; run +from source instead (`bun packages/cli/src/main.ts`, see the repository +README's "From source" section). ## Install 1. Download the binary matching your platform from this release's Assets: - macOS (Apple Silicon): `tecode-darwin-arm64` - - macOS (Intel): `tecode-darwin-x64` - Linux (x64): `tecode-linux-x64` - Linux (arm64): `tecode-linux-arm64` - Windows (x64): `tecode-windows-x64.exe` - - Windows (arm64): `tecode-windows-arm64.exe` 2. **Verify the checksum** (recommended) — each binary ships with a `.sha256` sibling asset: - macOS/Linux: `shasum -a 256 -c tecode--.sha256` diff --git a/requirements.md b/requirements.md index 94c429e..f194e29 100644 --- a/requirements.md +++ b/requirements.md @@ -204,7 +204,7 @@ The following points were open in the draft specification and are resolved here #### Acceptance Criteria 1. WHILE editing a 10,000-line file, THE system SHALL keep key-input-to-render latency within 16 ms. -2. THE distributable SHALL be a single self-contained binary produced by `bun build --compile` for darwin/linux/windows × x64/arm64, and each binary SHALL be no larger than 120 MB. +2. THE distributable SHALL be a single self-contained binary produced by `bun build --compile`, no larger than 120 MB; released binaries SHALL cover `bun-darwin-arm64`, `bun-linux-x64`, `bun-linux-arm64`, and `bun-windows-x64`. `bun-darwin-x64` and `bun-windows-arm64` SHALL NOT be published: no CI runner of either architecture exists (the release pipeline's hosted provider offers no Intel-macOS resource class since June 2024 and no Windows-arm64 resource class at all, and the project has no such hardware to self-host), and `@opentui/core`'s platform-native optional dependencies make cross-compiling either from another platform impossible. A user on either dropped platform SHALL be directed to run from source instead. 3. THE system SHALL support these terminals: Ghostty, Kitty, WezTerm, iTerm2, Windows Terminal, and running inside tmux, using the fallback keymap where the Kitty Keyboard Protocol is unavailable (per Requirement 4.7). 4. Each core module SHALL have `bun test` unit tests; the extension API SHALL be covered by contract tests; UI components SHALL be covered by snapshot tests using OpenTUI's headless renderer. diff --git a/scripts/release.test.ts b/scripts/release.test.ts index 466cb1b..2734782 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -2,7 +2,7 @@ * Unit tests for {@link release.ts}'s pure helpers (Req 8.5, 13.2, * design.md §17). Deliberately does NOT invoke a real `bun build --compile` * — that takes real wall-clock time, produces a real ~110 MB file, and (per - * this module's own TSDoc, Finding 2) fails outright for 5 of 6 targets on + * this module's own TSDoc, Finding 2) fails outright for 3 of 4 targets on * any single-platform machine, none of which belongs in the always-on `bun * test` suite. {@link buildTarget} itself is exercised for real instead by * this task's manual validation step (`bun run release bun-linux-x64`, @@ -10,6 +10,13 @@ * consuming its output — this suite covers only the logic that decides * WHICH targets to build, WHAT to name their output, and HOW to classify a * failure, all of which is cheap and deterministic to test directly. + * + * Also covers the one cross-file invariant between this script and + * `.circleci/config.yml`: that config's `publish` job hard-codes + * `PUBLISH_EXPECTED_BINARIES` rather than reading {@link RELEASE_TARGETS} + * (a shell script has no import statement), so nothing stops the two from + * drifting except this test — see the `PUBLISH_EXPECTED_BINARIES` describe + * block below. */ import { describe, expect, test } from "bun:test"; @@ -31,19 +38,24 @@ import { type ReleaseTarget, } from "./release"; -describe("RELEASE_TARGETS (Req 13.2's darwin/linux/windows × x64/arm64)", () => { - test("has exactly the 6 required platform/arch combinations", () => { - expect(RELEASE_TARGETS).toHaveLength(6); - const platforms = new Set(RELEASE_TARGETS.map((t) => t.platform)); - const archs = new Set(RELEASE_TARGETS.map((t) => t.arch)); - expect([...platforms].sort()).toEqual(["darwin", "linux", "windows"]); - expect([...archs].sort()).toEqual(["arm64", "x64"]); - // Every platform × arch pairing is present exactly once. - for (const platform of platforms) { - for (const arch of archs) { - expect(RELEASE_TARGETS.filter((t) => t.platform === platform && t.arch === arch)).toHaveLength(1); - } - } +describe("RELEASE_TARGETS (Req 13.2's published 4-target matrix)", () => { + test("is exactly the 4 targets a CircleCI runner actually exists for", () => { + // Asserting the real list, not a magic length: `toHaveLength(4)` alone + // would happily pass if `bun-darwin-x64` silently replaced + // `bun-linux-arm64`, say. Pinning the exact set (and, since arrays are + // ordered, the exact order) catches that a plain count cannot. + expect(RELEASE_TARGETS).toEqual([ + { bunTarget: "bun-darwin-arm64", platform: "darwin", arch: "arm64" }, + { bunTarget: "bun-linux-x64", platform: "linux", arch: "x64" }, + { bunTarget: "bun-linux-arm64", platform: "linux", arch: "arm64" }, + { bunTarget: "bun-windows-x64", platform: "windows", arch: "x64" }, + ]); + }); + + test("does NOT include the two dropped targets — no runner of either architecture exists", () => { + const bunTargets = RELEASE_TARGETS.map((t) => t.bunTarget); + expect(bunTargets).not.toContain("bun-darwin-x64"); + expect(bunTargets).not.toContain("bun-windows-arm64"); }); test("bunTarget strings match Bun's own bun-- naming", () => { @@ -53,6 +65,38 @@ describe("RELEASE_TARGETS (Req 13.2's darwin/linux/windows × x64/arm64)", () => }); }); +describe("PUBLISH_EXPECTED_BINARIES (.circleci/config.yml <-> RELEASE_TARGETS invariant)", () => { + /** A CircleCI step is either the bare string `"checkout"` or a + * single-key mapping like `{ run: { name, environment, command } }` — + * this pulls out just the `run` steps that carry an `environment` map, + * regardless of which other step shapes (`checkout`, `attach_workspace`, + * `persist_to_workspace`) sit alongside them. */ + interface CircleCiRunStep { + run?: { environment?: Record }; + } + + test("the publish job's expected binary count equals RELEASE_TARGETS.length", async () => { + const configPath = resolve(import.meta.dir, "..", ".circleci", "config.yml"); + const configText = await readFile(configPath, "utf8"); + const config = Bun.YAML.parse(configText) as { + jobs: { publish: { steps: Array } }; + }; + + const publishSteps = config.jobs.publish.steps; + const stepWithEnv = publishSteps.find( + (step): step is CircleCiRunStep => + typeof step === "object" && step.run?.environment?.PUBLISH_EXPECTED_BINARIES !== undefined, + ); + expect(stepWithEnv).toBeDefined(); + + // YAML has no distinct "quoted string that looks like a number" type + // marker once parsed — `"4"` and `4` both come back needing this same + // coercion, so Number(...) covers either spelling. + const expected = Number(stepWithEnv!.run!.environment!.PUBLISH_EXPECTED_BINARIES); + expect(expected).toBe(RELEASE_TARGETS.length); + }); +}); + describe("SIZE_LIMIT_BYTES (Req 13.2's 120 MB budget)", () => { test("uses the stricter decimal-MB reading, not the binary MiB one", () => { expect(SIZE_LIMIT_BYTES).toBe(120_000_000); @@ -109,7 +153,7 @@ describe("classifyBuildFailure (Finding 2's cross-compile limitation)", () => { }); describe("parseTargetFilter", () => { - test("with no arguments, defaults to all 6 targets", () => { + test("with no arguments, defaults to all 4 targets", () => { const { targets, unknown } = parseTargetFilter([]); expect(targets).toEqual([...RELEASE_TARGETS]); expect(unknown).toEqual([]); @@ -122,8 +166,14 @@ describe("parseTargetFilter", () => { }); test("with multiple target names, builds exactly those, in RELEASE_TARGETS order matching input order", () => { - const { targets } = parseTargetFilter(["bun-windows-x64", "bun-darwin-x64"]); - expect(targets.map((t: ReleaseTarget) => t.bunTarget)).toEqual(["bun-windows-x64", "bun-darwin-x64"]); + const { targets } = parseTargetFilter(["bun-windows-x64", "bun-darwin-arm64"]); + expect(targets.map((t: ReleaseTarget) => t.bunTarget)).toEqual(["bun-windows-x64", "bun-darwin-arm64"]); + }); + + test("a dropped target name (bun-darwin-x64, bun-windows-arm64) is reported as unknown, not built", () => { + const { targets, unknown } = parseTargetFilter(["bun-darwin-x64", "bun-windows-arm64"]); + expect(targets).toEqual([]); + expect(unknown).toEqual(["bun-darwin-x64", "bun-windows-arm64"]); }); test("an unrecognized target name is reported in unknown, not silently dropped", () => { @@ -216,9 +266,9 @@ describe("formatChecksumLine (sha256sum/shasum -a 256 compatible output)", () => }); test("uses only binaryFileName's bare output, never a path", () => { - const target = RELEASE_TARGETS.find((t) => t.bunTarget === "bun-windows-arm64")!; + const target = RELEASE_TARGETS.find((t) => t.bunTarget === "bun-windows-x64")!; const line = formatChecksumLine("cafebabe", binaryFileName(target)); - expect(line).toBe("cafebabe tecode-windows-arm64.exe\n"); + expect(line).toBe("cafebabe tecode-windows-x64.exe\n"); }); }); diff --git a/scripts/release.ts b/scripts/release.ts index 1e3d81e..1e4cb94 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,16 +1,21 @@ /** * Drives the compiled-binary release matrix (Issue #35 "4.4 Compiled binary * builds"; Req 8.5, 13.2; design.md §17: "Release: `bun build --compile` per - * target (darwin/linux/windows × x64/arm64) from `cli`... A `scripts/ - * release.ts` drives the 6-target matrix and size assertions."), plus - * (Issue #38 "5.2 User documentation and release") a SHA-256 checksum + * target (darwin/linux/windows × x64/arm64 in principle) from `cli`... A + * `scripts/release.ts` drives the 4-target matrix and size assertions."), + * plus (Issue #38 "5.2 User documentation and release") a SHA-256 checksum * written alongside each successfully built binary — {@link sha256Hex}, * {@link formatChecksumLine}, {@link computeChecksumLine}, and * {@link writeChecksumFile} below — for the release workflow's publish job * to attach to the GitHub Release next to the binary it describes. * + * **Only four of the six theoretically-possible targets are actually + * published** — see "Two targets were dropped, not just left off this + * machine" below for why `bun-darwin-x64` and `bun-windows-arm64` are gone + * from {@link RELEASE_TARGETS} entirely, not merely unbuilt here. + * * Usage: `bun run release [target ...]` — with no arguments, attempts all - * six {@link RELEASE_TARGETS}; with one or more `bun build --target=...` + * four {@link RELEASE_TARGETS}; with one or more `bun build --target=...` * names (e.g. `bun run release bun-linux-x64`), builds only those. This is * what lets a single-platform CI runner build just its own target (below). * @@ -72,7 +77,7 @@ * needed fixing for this task; {@link RELEASE_TARGETS}/{@link buildTarget} * below exist purely to drive the matrix and assert the size budget. * - * ## Why this machine cannot produce all six binaries + * ## Why this machine cannot produce even the four remaining binaries * * `@opentui/core` ships six platform-specific optional dependencies * (`@opentui/core-{darwin,linux,win32}-{x64,arm64}`), each carrying `os`/ @@ -97,14 +102,46 @@ * break, so a CI matrix runner building its own native target (where this * limitation never triggers — see below) is never confused with a real * regression, and a human reading this script's output on a single machine - * understands immediately why 5 of 6 targets "failed" here. + * understands immediately why 3 of 4 targets "failed" here. * * **The fix is the matrix, not this script**: each target must be built on * a runner of that platform, so its OWN `@opentui/core--` * links natively and the dynamic import above resolves normally. That is - * exactly Issue #36's tag-triggered release matrix — this script's target - * filter (`bun run release `) is the seam that matrix uses: each - * platform's runner invokes this script with just its own target name. + * exactly the tag-triggered CircleCI release pipeline (`.circleci/ + * config.yml`) — this script's target filter (`bun run release `) + * is the seam that pipeline uses: each platform's runner (two of them + * self-hosted — see that config's own comments) invokes this script with + * just its own target name. + * + * ## Two targets were dropped, not just left off this machine + * + * The matrix above is deliberately four targets, not six: `bun-darwin-x64` + * and `bun-windows-arm64` are not merely "not built on this machine" the + * way the other three non-host targets are — they are not in + * {@link RELEASE_TARGETS} at all, because no machine anywhere in this + * project's CI can build them, and cross-compiling them from another + * platform is exactly as impossible as the paragraph above describes for + * any other foreign target: + * + * - **`bun-darwin-x64` (Intel macOS)**: CircleCI removed every Intel macOS + * resource class in June 2024 — its hosted `macos` executor is Apple + * silicon only. The project owner's own Mac is Apple silicon + * (`bun-darwin-arm64`, self-hosted below); they have no Intel Mac to + * build or verify an Intel binary on. + * - **`bun-windows-arm64` (Windows on Arm)**: CircleCI offers no Windows + * arm64 resource class, hosted or self-hosted. The owner's in-house + * Windows machine is x64 (`bun-windows-x64`, self-hosted below); they + * have no Windows-on-Arm device either. + * + * These two were considered and explicitly ruled out — not forgotten. If a + * future CircleCI release adds an Intel-macOS or Windows-arm64 resource + * class, or the project gains access to that hardware, re-adding the + * corresponding entry to {@link RELEASE_TARGETS} (and a matching job to + * `.circleci/config.yml`, and bumping that config's + * `PUBLISH_EXPECTED_BINARIES`) is the only work needed — nothing else in + * this script is target-count-specific. Until then, a user on either + * platform has no binary to download; the README's "From source" section + * (`bun run packages/cli/src/main.ts`) is what to point them at. * * ## The `TECODE_BIN` smoke-test convention * @@ -122,7 +159,7 @@ * * ## What this script cannot verify by itself * - * The five non-host targets' real builds, Windows `%APPDATA%\tecode\` + * The three non-host targets' real builds, Windows `%APPDATA%\tecode\` * resolution on an actual Windows machine, and an interactive clean-machine * check (open a directory, highlight a file, switch themes, on a real * terminal) all need a platform or a TTY this script's own environment does @@ -143,17 +180,22 @@ export interface ReleaseTarget { readonly arch: "x64" | "arm64"; } -/** The full 6-target release matrix (Req 13.2, design.md §17). Order is +/** The real, published 4-target release matrix (Req 13.2, design.md §17) — + * NOT the full darwin/linux/windows × x64/arm64 cross-product design.md §17 + * describes "in principle". `bun-darwin-x64` and `bun-windows-arm64` are + * deliberately absent: CircleCI has no Intel-macOS resource class (removed + * June 2024) and no Windows-arm64 resource class at all, and the project + * owner has neither an Intel Mac nor a Windows-on-Arm machine to self-host + * either one — see this module's TSDoc, "Two targets were dropped, not + * just left off this machine", for the full reasoning. Order is * deterministic (platform, then arch) purely for stable, readable output — * it has no bearing on correctness since every target builds * independently. */ export const RELEASE_TARGETS: readonly ReleaseTarget[] = [ - { bunTarget: "bun-darwin-x64", platform: "darwin", arch: "x64" }, { bunTarget: "bun-darwin-arm64", platform: "darwin", arch: "arm64" }, { bunTarget: "bun-linux-x64", platform: "linux", arch: "x64" }, { bunTarget: "bun-linux-arm64", platform: "linux", arch: "arm64" }, { bunTarget: "bun-windows-x64", platform: "windows", arch: "x64" }, - { bunTarget: "bun-windows-arm64", platform: "windows", arch: "arm64" }, ]; /** @@ -311,7 +353,7 @@ export function classifyBuildFailure(stderr: string): string | undefined { "known limitation: @opentui/core's platform-native optional dependency " + "cannot be cross-compiled from this host — this target must be built " + "on a runner of its own platform (see this script's TSDoc, \"Why this " + - "machine cannot produce all six binaries\")" + "machine cannot produce even the four remaining binaries\")" ); } return undefined; @@ -414,8 +456,9 @@ export async function buildTarget( } /** Parse `bun run release [target ...]` arguments into the targets to - * build (see this module's TSDoc "Usage"). No arguments means "all six" — - * matches this task's own "default to all six" requirement. Any argument + * build (see this module's TSDoc "Usage"). No arguments means "all four + * {@link RELEASE_TARGETS}" — matches this task's own "default to all + * targets" requirement. Any argument * that doesn't name a real {@link RELEASE_TARGETS} entry is reported back * in `unknown` rather than silently ignored — a typo'd target name should * be loud, not a quiet no-op. */ @@ -450,7 +493,7 @@ async function main(argv: string[]): Promise { // Every target NOT requested this run is logged as not-built, never // silently dropped — this task's own "whatever you don't build, log as // not built" requirement, most relevant when a CI runner passes a single - // target filter and the other five are expected to be built elsewhere. + // target filter and the other three are expected to be built elsewhere. const requested = new Set(targets.map((t) => t.bunTarget)); for (const target of RELEASE_TARGETS) { if (!requested.has(target.bunTarget)) { diff --git a/tasks.md b/tasks.md index b980672..946dd10 100644 --- a/tasks.md +++ b/tasks.md @@ -182,7 +182,7 @@ Conventions for every task: - _Req 11.7; Design §13_ - [ ] **4.4 Compiled binary builds** - - `bun build --compile` from `cli` with embedded assets (theme JSON, grammar WASMs, `.scm` queries, fallback keymap); asset-URI indirection verified in compiled mode; `scripts/release.ts` for the darwin/linux/windows × x64/arm64 matrix; Windows `%APPDATA%\tecode\` path handling behind the `paths` module. + - `bun build --compile` from `cli` with embedded assets (theme JSON, grammar WASMs, `.scm` queries, fallback keymap); asset-URI indirection verified in compiled mode; `scripts/release.ts` for the published 4-target matrix (`bun-darwin-arm64`, `bun-linux-x64`, `bun-linux-arm64`, `bun-windows-x64` — `bun-darwin-x64`/`bun-windows-arm64` dropped for lack of a CI runner of either architecture); Windows `%APPDATA%\tecode\` path handling behind the `paths` module. - _Req 8.5, 13.2; Design §17_ - [ ] **4.5 CI pipeline** @@ -200,7 +200,7 @@ Conventions for every task: - _Req 2.3, 2.7, 10; Design §4, §12_ - [ ] **5.2 User documentation and release** - - README (install, keybindings table, settings reference, terminal support matrix, fallback-keymap notes), sample `settings.json`/`keybindings.json`, versioned release publishing of the 6 binaries. + - README (install, keybindings table, settings reference, terminal support matrix, fallback-keymap notes), sample `settings.json`/`keybindings.json`, versioned release publishing of the 4 binaries via the tag-triggered CircleCI pipeline (`.circleci/config.yml`). - _Req 9.5, 13.2, 13.3_ --- From 62ec43bf7303a71012779d9a461d68a728809e99 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 07:58:39 +0000 Subject: [PATCH 3/4] Drop an orphaned comment block from the CircleCI config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block documenting PUBLISH_EXPECTED_BINARIES sat between `version: 2.1` and `executors:`, describing a key that lives on the `publish` job's own `run` step several hundred lines below — it documented nothing where it stood, and the same explanation is already attached to the real key. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- .circleci/config.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5442a19..aab8530 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -78,15 +78,6 @@ version: 2.1 -# How many binaries (and therefore how many .sha256 files) the publish job -# requires before it will create a release. Four, because that is what the -# workflow below actually builds — raise it if you add the two missing -# targets back via self-hosted runners. -# -# This gate exists so a partial matrix can never publish a release that -# silently omits a platform: a user downloading it would find their build -# missing with no indication anything went wrong. - executors: linux-x64: machine: From ef0bc13f8ba6eb25c9014ac55794b28f3383c323 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:11:28 +0000 Subject: [PATCH 4/4] Address CodeRabbit review on the CircleCI release pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all confirmed against the config as written: 1. Bracketed resource-class placeholders are not merely unmatched, they are INVALID. CircleCI's config compiler rejects `/macos-arm64` outright ("Invalid format in resource class"), so the whole pipeline fails to compile rather than the two self-hosted jobs simply queuing. Both are now written as real, format-legal names (`goofmint/macos-arm64`, `goofmint/windows-x64`); the comment says they must still be registered with `circleci runner resource-class create` before the first tag push, and how to confirm the namespace. 2. The GitHub Release was created with the API's default `draft: false`, so it became publicly reachable the instant it was created — a visitor landing there mid-upload would see a real release missing however many binaries had not arrived, indistinguishable from a finished one. It is now created as a draft and PATCHed to `draft: false` only after the last asset uploads. `set -e` plus curl's `-f` means a failed upload aborts before that PATCH, leaving an invisible draft to clean up. 3. `$USERPROFILE` is a Windows path (`C:\Users\...`), and its drive-letter colon is the POSIX PATH separator — prepending it under `bash.exe` split the entry in half and left `bun` unfindable. Converted with `cygpath -u`. The PowerShell installer step also gains `$ErrorActionPreference = "Stop"` so a failed install surfaces there rather than as a confusing "bun: command not found" one step later. Verified by extracting the publish step's script from the YAML and running it against a stubbed curl: the create payload carries `draft: true`, all eight assets upload, and the PATCH publishes last. Mutating a mid-loop upload to fail proves the PATCH never runs. A tag of `v1.0.0-$(id -un)-` plus a backtick expression reaches the API as a literal string, confirming CIRCLE_TAG is still never shell-interpolated. CodeRabbit's docstring-coverage warning is not addressed: the diff adds no undocumented exported symbol, and the functions it counts are test-case arrow callbacks, which no test in this repo documents. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WELSsojQQL1cTAR5iUUsTK --- .circleci/config.yml | 81 ++++++++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index aab8530..7c51667 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -48,24 +48,34 @@ # ever added back (new CircleCI resource class, or new owned hardware), both # this file and `scripts/release.ts` need updating together. # -# ## Self-hosted runners: what to substitute, and how to register one +# ## Self-hosted runners: register these before the first tag push # -# The `macos-arm64-self-hosted` and `windows-x64-self-hosted` executors below -# are PLACEHOLDERS naming a `/` pair that does not -# exist until you create it. For EACH of the owner's two machines (the Apple -# Silicon Mac and the in-house Windows x64 box): +# `build-darwin-arm64` and `build-windows-x64` target the resource classes +# `goofmint/macos-arm64` and `goofmint/windows-x64`. Those names are written +# out in full rather than left as `/`-style placeholders +# because `<` and `>` are not legal in a resource class name: CircleCI's +# config compiler rejects the whole file ("Invalid format in resource class") +# before any job runs, so a bracketed placeholder does not merely fail to +# match a runner — it breaks the pipeline outright. # -# 1. `circleci runner resource-class create / "" --generate-token` -# (run once per machine, from a machine with the CircleCI CLI installed -# and authenticated — this is a CircleCI *project/org* namespace you -# choose, not a hostname). This prints a runner AUTH TOKEN — copy it, -# it is shown only once. +# They are still names that DO NOT EXIST until created. `goofmint` is the +# expected CircleCI namespace (it defaults to the VCS org name); confirm it +# with `circleci namespace` and change both here if yours differs. For EACH +# of the two machines (the Apple Silicon Mac and the in-house Windows x64 +# box): +# +# 1. `circleci runner resource-class create goofmint/macos-arm64 "" --generate-token` +# (and likewise for `goofmint/windows-x64`) — run once per machine, +# from anywhere with the CircleCI CLI installed and authenticated. +# This prints a runner AUTH TOKEN — copy it, it is shown only once. # 2. Install "machine runner 3" on that physical machine (CircleCI's # installer for macOS/Windows), configuring it with the resource class # name and auth token from step 1. The runner then polls CircleCI for # jobs targeting that resource class and executes them locally. -# 3. Replace `/macos-arm64` and `/windows-x64` below -# with the REAL `/` pairs step 1 created. +# +# Until step 1 runs for a given machine, its build job stays queued with no +# runner to claim it, and `publish` — which `requires` all four builds — +# never starts. Nothing is published from a half-registered setup. # # ## Required project configuration # @@ -88,11 +98,11 @@ executors: image: ubuntu-2404:current resource_class: arm.medium # SELF-HOSTED — the owner's own Apple Silicon Mac, not a CircleCI-hosted - # image. `/macos-arm64` is a PLACEHOLDER: replace it with the - # real `/` pair created by running (once, from a machine - # with the CircleCI CLI authenticated against this project): + # image. `goofmint/macos-arm64` does not exist until it is registered; + # create it once, from anywhere with the CircleCI CLI authenticated + # against this project: # - # circleci runner resource-class create /macos-arm64 \ + # circleci runner resource-class create goofmint/macos-arm64 \ # "tecode release: owner's Apple Silicon Mac" --generate-token # # then install "machine runner 3" on that Mac using the resource class name @@ -103,12 +113,12 @@ executors: # above; it is NOT the hosted `macos:` executor type. macos-arm64-self-hosted: machine: true - resource_class: "/macos-arm64" + resource_class: goofmint/macos-arm64 # SELF-HOSTED — the owner's own in-house Windows x64 machine. Same - # placeholder convention as above: replace `/windows-x64` with - # the real pair from + # register-before-first-use rule as above; create `goofmint/windows-x64` + # with # - # circleci runner resource-class create /windows-x64 \ + # circleci runner resource-class create goofmint/windows-x64 \ # "tecode release: owner's Windows x64 box" --generate-token # # then install machine runner 3 on that Windows machine with the resulting @@ -120,7 +130,7 @@ executors: # default to resolve. windows-x64-self-hosted: machine: true - resource_class: "/windows-x64" + resource_class: goofmint/windows-x64 shell: bash.exe commands: @@ -197,6 +207,7 @@ jobs: name: Install Bun shell: powershell.exe command: | + $ErrorActionPreference = "Stop" irm bun.sh/install.ps1 | iex - run: name: Build bun-windows-x64 @@ -204,7 +215,7 @@ jobs: # is not on PATH in this shell yet. command: | set -euo pipefail - export PATH="$USERPROFILE/.bun/bin:$PATH" + export PATH="$(cygpath -u "$USERPROFILE")/.bun/bin:$PATH" bun --version bun install --frozen-lockfile bun run release bun-windows-x64 @@ -259,6 +270,16 @@ jobs: api="https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" notes=$(cat docs/release-notes-template.md) + + # Created as a DRAFT, flipped to published only after the last + # asset uploads. The API's own default is draft:false, which + # would make the release page reachable the instant it is + # created — someone landing there mid-upload would see a real + # release missing however many binaries had not arrived yet, + # with nothing to distinguish it from a finished one. A draft + # is invisible to everyone but repo writers, so a failed upload + # leaves an unpublished draft to clean up rather than a public + # release that silently omits a platform. payload=$( TAG="$CIRCLE_TAG" NOTES="$notes" python3 -c ' import json, os @@ -266,26 +287,36 @@ jobs: "tag_name": os.environ["TAG"], "name": "tecode " + os.environ["TAG"], "body": os.environ["NOTES"], + "draft": True, }))' ) - upload_url=$( + created=$( curl -fsSL -X POST "$api/releases" \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ - -d "$payload" | - python3 -c 'import json,sys; print(json.load(sys.stdin)["upload_url"].split("{")[0])' + -d "$payload" ) + release_id=$(printf '%s' "$created" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') + upload_url=$(printf '%s' "$created" | python3 -c 'import json,sys; print(json.load(sys.stdin)["upload_url"].split("{")[0])') for asset in dist/tecode-*; do name=$(basename "$asset") echo "release: uploading $name" + # `set -e` plus curl's `-f` means a failed upload aborts the + # step here, before the publish below — the draft stays a + # draft, which is the intended failure mode. curl -fsSL -X POST "$upload_url?name=$name" \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Content-Type: application/octet-stream" \ --data-binary "@$asset" > /dev/null done + curl -fsSL -X PATCH "$api/releases/$release_id" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -d '{"draft":false}' > /dev/null + echo "release: published $CIRCLE_TAG" workflows: