diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..7c51667 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,348 @@ +# CircleCI release pipeline for tecode (Issue: tag-triggered binary release). +# +# 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 +# +# `@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 even the four remaining binaries". Each +# target must be built on its own architecture. +# +# ## Only four targets are published — two are gone for good, not deferred +# +# `RELEASE_TARGETS` in `scripts/release.ts` ships exactly these four: +# +# 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 +# +# `bun-darwin-x64` (Intel macOS) and `bun-windows-arm64` (Windows on Arm) are +# NOT built by anything, anywhere, in this pipeline: +# +# - 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. +# +# 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: register these before the first tag push +# +# `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. +# +# 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. +# +# 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 +# +# - `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. +# - 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 + +executors: + linux-x64: + machine: + image: ubuntu-2404:current + resource_class: medium + linux-arm64: + machine: + image: ubuntu-2404:current + resource_class: arm.medium + # SELF-HOSTED — the owner's own Apple Silicon Mac, not a CircleCI-hosted + # 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 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 + # 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: goofmint/macos-arm64 + # SELF-HOSTED — the owner's own in-house Windows x64 machine. Same + # register-before-first-use rule as above; create `goofmint/windows-x64` + # with + # + # 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 + # 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: goofmint/windows-x64 + shell: bash.exe + +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-self-hosted + steps: + - checkout + - install-bun-posix + - build-and-persist: + target: bun-darwin-arm64 + + build-windows-x64: + executor: windows-x64-self-hosted + steps: + - checkout + - run: + name: Install Bun + shell: powershell.exe + command: | + $ErrorActionPreference = "Stop" + 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="$(cygpath -u "$USERPROFILE")/.bun/bin:$PATH" + bun --version + bun install --frozen-lockfile + bun run release bun-windows-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. + # + # 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: | + 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) + + # 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 + print(json.dumps({ + "tag_name": os.environ["TAG"], + "name": "tecode " + os.environ["TAG"], + "body": os.environ["NOTES"], + "draft": True, + }))' + ) + + created=$( + curl -fsSL -X POST "$api/releases" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -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: + 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 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_ ---