diff --git a/.circleci/config.yml b/.circleci/config.yml index 7c51667..53dcade 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,11 +1,25 @@ # 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. +# Pushing a `v*` tag builds one compiled binary per CircleCI-built target on +# a runner of that target's OWN platform, then attaches them to the GitHub +# Release `bun run tag` (`scripts/tagRelease.ts`) already created as a draft +# and publishes it. 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. +# +# ## THIS PIPELINE NEVER CREATES A RELEASE — IT ONLY FINISHES ONE +# +# Pushing a `v*` tag by hand (`git tag v1.2.3 && git push origin v1.2.3`) +# instead of running `bun run tag v1.2.3` on the owner's Mac does NOT +# produce a release. It produces a STUCK pipeline: `publish` (below) looks +# for a draft release matching the tag, finds none (`bun run tag` is the +# only thing that ever creates one), and fails loudly rather than creating +# one itself — a release this pipeline created on its own would permanently +# be missing the macOS binary, since nothing else here builds +# `bun-darwin-arm64`. The only supported way to cut a release is `bun run +# tag ` from the project owner's Apple Silicon Mac; see that +# script's own TSDoc (`scripts/tagRelease.ts`) for its full preflight/build/ +# release/tag sequence. # # ## Why one job per target, and not a build matrix on one machine # @@ -20,15 +34,27 @@ # # ## Only four targets are published — two are gone for good, not deferred # -# `RELEASE_TARGETS` in `scripts/release.ts` ships exactly these four: +# `RELEASE_TARGETS` in `scripts/release.ts` ships exactly these four, split +# by who builds each one (`ReleaseTarget.builtBy`): +# +# bun-darwin-arm64 builtBy "local" — the owner's own Apple Silicon +# Mac, via `bun run tag`; NO +# CircleCI job builds this target +# bun-linux-x64 builtBy "circleci" — CircleCI-hosted machine executor +# bun-linux-arm64 builtBy "circleci" — CircleCI-hosted machine executor, arm.medium +# bun-windows-x64 builtBy "circleci" — self-hosted runner, the owner's own Windows x64 box # -# 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 owner did not want to install a CircleCI machine runner on their own +# Mac, so `bun-darwin-arm64` is built locally instead of by a +# `build-darwin-arm64` job — there is no such job, and no +# `macos-arm64-self-hosted` executor, in this file. Its binary and checksum +# reach the release by a different path than the other three: `bun run tag` +# uploads them to a draft release BEFORE the tag is pushed, and `publish` +# below finds that draft and adds its own three assets on top of them, +# rather than building all four itself. # # `bun-darwin-x64` (Intel macOS) and `bun-windows-arm64` (Windows on Arm) are -# NOT built by anything, anywhere, in this pipeline: +# NOT built by anything, anywhere, in this pipeline or by `bun run tag`: # # - CircleCI removed every Intel-macOS resource class in June 2024 — its # hosted `macos` executor is Apple silicon only, and the owner has no @@ -42,49 +68,81 @@ # 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. +# `PUBLISH_EXPECTED_CI_ASSETS` and `PUBLISH_EXPECTED_RELEASE_ASSETS` below +# list exact file names — the artifacts this workflow's own build jobs must +# produce, and the ones the finished release must carry. They MUST always +# equal, respectively, the `builtBy: "circleci"` subset of `RELEASE_TARGETS` +# and all of it (`scripts/release.test.ts` derives both lists from +# `RELEASE_TARGETS` and fails the build if either drifts from `scripts/ +# release.ts`), so if a target is added, dropped, renamed, or moves between +# `"local"`/`"circleci"`, this file and `scripts/release.ts` need updating +# together. # -# ## Self-hosted runners: register these before the first tag push +# ## Self-hosted runner: register this 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. +# `build-windows-x64` targets the resource class `goofmint/windows-x64`. +# That name is written out in full rather than left as a +# `/`-style placeholder 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 +# It is still a name that DOES 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): +# with `circleci namespace` and change it here if yours differs. On 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. +# 1. `circleci runner resource-class create goofmint/windows-x64 "" --generate-token` +# — run once, 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. +# installer for 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. +# Until step 1 runs, `build-windows-x64` stays queued with no runner to +# claim it, and `publish` — which `requires` all three CircleCI build jobs +# — never starts. Nothing is published from a half-registered setup. There +# is no equivalent step for macOS: `bun run tag` runs directly on the +# owner's Mac, outside CircleCI entirely, so no runner is ever registered +# for it. # # ## 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. +# - `TECODE_RELEASE_TOKEN` — an environment variable in CircleCI project +# settings, holding a GitHub token this `publish` job uses for its own +# REST API calls (list releases, upload assets, PATCH draft:false). +# Deliberately NOT named `GITHUB_TOKEN`: that name is common enough +# ambient tooling convention that something else could already be +# supplying it with broader scope than this job needs, and this name +# guarantees the value present is only ever here because someone set it +# FOR this job. Unlike GitHub Actions there is no ambient `github.token` +# on CircleCI; nothing here can publish without this variable set. +# +# Minimum scope: a FINE-GRAINED personal access token, "Repository +# access" limited to only `goofmint/tecode`, with exactly one repository +# permission — Contents: Read and write (GitHub files releases under +# Contents; this is what lets `publish` upload assets and flip +# draft:false). Do NOT use a classic PAT here — its `repo` scope reaches +# every repository the token's owner can access, plus issues, with no way +# to narrow it to just this one repository's releases. This token is used +# ONLY for the GitHub REST API calls in this job; CircleCI's own +# `checkout` step uses its own separate credentials for `git`, unrelated +# to this token. +# +# `bun run tag` needs its OWN `TECODE_RELEASE_TOKEN` set in the +# environment it runs in (the owner's Mac) — see `scripts/tagRelease.ts`'s +# `checkReleaseTokenSet` for that copy's own scope requirement (identical: +# fine-grained, `goofmint/tecode` only, Contents: Read and write). The two +# are separate credentials in separate places, never shared — rotate or +# revoke either independently of the other. Neither copy of this token +# needs any git push access: pushing the release tag itself goes through +# ordinary `git` credentials on whichever machine runs `bun run tag`, not +# through this token. +# - The self-hosted Windows runner's auth token from step 1 above, +# configured on that machine's runner install — not stored in this repo. version: 2.1 @@ -97,26 +155,11 @@ executors: 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 + # SELF-HOSTED — the owner's own in-house Windows x64 machine, not a + # CircleCI-hosted image. `goofmint/windows-x64` does not exist until it is + # registered (this file's top-of-file comment, "Self-hosted runner: + # register this before the first tag push"); create it once, from + # anywhere with the CircleCI CLI authenticated against this project: # # circleci runner resource-class create goofmint/windows-x64 \ # "tecode release: owner's Windows x64 box" --generate-token @@ -191,14 +234,6 @@ jobs: - 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: @@ -232,88 +267,155 @@ jobs: - 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. + name: Refuse to publish unless every CircleCI-built artifact is present + # This checks only THIS WORKFLOW's own three build jobs' output in + # the shared workspace — bun-darwin-arm64 (builtBy "local" in + # scripts/release.ts's RELEASE_TARGETS) is never in here, because + # no CircleCI job builds it; it already reached the draft release + # directly, via `bun run tag`, before this workflow even started. # - # 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. + # MUST always equal the number of `builtBy: "circleci"` entries in + # `RELEASE_TARGETS` (`scripts/release.ts`, currently 3) — + # `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 its builtBy split, or that test fails. environment: - PUBLISH_EXPECTED_BINARIES: "4" + # The exact file names build-linux-x64, build-linux-arm64 and + # build-windows-x64 must have left in the shared workspace — + # NOT a count. A count of 3 is satisfied by three files with + # the wrong names, which is precisely the shape a rename or a + # half-migrated target set produces, so match the set instead. + PUBLISH_EXPECTED_CI_ASSETS: >- + tecode-linux-x64 + tecode-linux-x64.sha256 + tecode-linux-arm64 + tecode-linux-arm64.sha256 + tecode-windows-x64.exe + tecode-windows-x64.exe.sha256 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 + found=$(find dist -maxdepth 1 -type f -name 'tecode-*' -exec basename {} \; | sort) + expected=$(printf '%s\n' $PUBLISH_EXPECTED_CI_ASSETS | sort) + missing=$(comm -13 <(printf '%s\n' "$found") <(printf '%s\n' "$expected") | tr '\n' ' ') + unexpected=$(comm -23 <(printf '%s\n' "$found") <(printf '%s\n' "$expected") | tr '\n' ' ') + if [ -n "${missing// /}" ] || [ -n "${unexpected// /}" ]; then + echo "release: refusing to publish — this workflow's own build jobs did not produce exactly the expected artifacts" >&2 + [ -n "${missing// /}" ] && echo "release: missing: $missing" >&2 + [ -n "${unexpected// /}" ] && echo "release: unexpected: $unexpected" >&2 exit 1 fi + echo "release: all 6 expected CircleCI-built artifacts present" - run: - name: Create the GitHub Release and upload every asset + name: Find the bun run tag draft release, attach this workflow's assets, and publish # 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. + # + # Every asset the FINISHED release must carry: one binary and + # one checksum per RELEASE_TARGETS entry, the `bun run tag` + # macOS pair included. Distinct from PUBLISH_EXPECTED_CI_ASSETS + # above, which is only what THIS workflow's build jobs produce. + # + # Names, not a count, for the reason above and one more specific + # to this check: the release already holds assets uploaded by a + # different machine at a different time (`bun run tag`), so "4 + # binaries are present" does not establish that the macOS one is + # among them — a stray or misnamed asset makes the arithmetic + # work out while the platform a user came for is still missing. + # scripts/release.test.ts pins this list against RELEASE_TARGETS. + environment: + PUBLISH_EXPECTED_RELEASE_ASSETS: >- + tecode-darwin-arm64 + tecode-darwin-arm64.sha256 + tecode-linux-x64 + tecode-linux-x64.sha256 + tecode-linux-arm64 + tecode-linux-arm64.sha256 + tecode-windows-x64.exe + tecode-windows-x64.exe.sha256 command: | set -euo pipefail - : "${GITHUB_TOKEN:?set GITHUB_TOKEN in CircleCI project settings (needs contents: write)}" + : "${TECODE_RELEASE_TOKEN:?set TECODE_RELEASE_TOKEN in CircleCI project settings (a fine-grained PAT scoped to only goofmint/tecode, Contents: Read and 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) + # GET /releases/tags/{tag} does NOT reliably return draft + # releases (confirmed against the GitHub REST API) — list and + # match on tag_name + draft ourselves instead. This mirrors + # scripts/tagRelease.ts's own findDraftReleaseByTag, the same + # selection algorithm, unit-tested there; bash has no import + # statement, so this shell implementation is verified instead by + # actually executing it against a stubbed curl (see this + # change's own validation notes). + # One page suffices: the API lists releases newest-first and + # `bun run tag` creates this draft seconds before pushing the + # tag that started this job, so it is effectively always the + # newest release. 100 leaves room for a pathological backlog + # without paginating. + releases=$(curl -fsSL "$api/releases?per_page=100" \ + -H "Authorization: Bearer $TECODE_RELEASE_TOKEN" \ + -H "Accept: application/vnd.github+json") - # 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, - }))' - ) + release_id=$(printf '%s' "$releases" | TAG="$CIRCLE_TAG" python3 -c ' + import json, os, sys + releases = json.loads(sys.stdin.read()) + tag = os.environ["TAG"] + for r in releases: + if r.get("tag_name") == tag and r.get("draft") is True: + print(r["id"]) + sys.exit(0) + sys.exit(1) + ') || { + echo "release: no draft release found for tag $CIRCLE_TAG" >&2 + echo "release: this tag was pushed WITHOUT running 'bun run tag' on the owner's Apple Silicon Mac first — that command builds bun-darwin-arm64 and creates the draft release BEFORE pushing the tag, and this pipeline never creates one itself (it would permanently omit the macOS binary). The macOS binary will never arrive this way. Delete this tag on GitHub, then run 'bun run tag ${CIRCLE_TAG#v}' (or 'bun run tag $CIRCLE_TAG') from that Mac — it will push the tag itself once it succeeds." >&2 + exit 1 + } + echo "release: found draft release id $release_id for tag $CIRCLE_TAG" - 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])') + release=$(curl -fsSL "$api/releases/$release_id" \ + -H "Authorization: Bearer $TECODE_RELEASE_TOKEN" \ + -H "Accept: application/vnd.github+json") + upload_url=$(printf '%s' "$release" | 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. + # step here, before the completeness check and PATCH 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 "Authorization: Bearer $TECODE_RELEASE_TOKEN" \ -H "Content-Type: application/octet-stream" \ --data-binary "@$asset" > /dev/null done + # Re-fetch to see the assets bun run tag AND this job both + # uploaded, and confirm the release now holds all four binaries + # and all four checksums before flipping it public — a partial + # release must never go live silently missing a platform. + release=$(curl -fsSL "$api/releases/$release_id" \ + -H "Authorization: Bearer $TECODE_RELEASE_TOKEN" \ + -H "Accept: application/vnd.github+json") + attached=$(printf '%s' "$release" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)["assets"]))' | sort) + expected=$(printf '%s\n' $PUBLISH_EXPECTED_RELEASE_ASSETS | sort) + echo "release: release holds: $(printf '%s' "$attached" | tr '\n' ' ')" + missing=$(comm -13 <(printf '%s\n' "$attached") <(printf '%s\n' "$expected") | tr '\n' ' ') + unexpected=$(comm -23 <(printf '%s\n' "$attached") <(printf '%s\n' "$expected") | tr '\n' ' ') + if [ -n "${missing// /}" ] || [ -n "${unexpected// /}" ]; then + echo "release: refusing to publish — the release does not hold exactly the expected assets" >&2 + [ -n "${missing// /}" ] && echo "release: missing: $missing" >&2 + [ -n "${unexpected// /}" ] && echo "release: unexpected: $unexpected" >&2 + exit 1 + fi + curl -fsSL -X PATCH "$api/releases/$release_id" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Authorization: Bearer $TECODE_RELEASE_TOKEN" \ -H "Accept: application/vnd.github+json" \ -d '{"draft":false}' > /dev/null @@ -335,8 +437,6 @@ workflows: ignore: /.*/ - build-linux-arm64: filters: *release-tags - - build-darwin-arm64: - filters: *release-tags - build-windows-x64: filters: *release-tags - publish: @@ -344,5 +444,4 @@ workflows: requires: - build-linux-x64 - build-linux-arm64 - - build-darwin-arm64 - build-windows-x64 diff --git a/README.md b/README.md index bf982ce..0d377a3 100644 --- a/README.md +++ b/README.md @@ -49,15 +49,68 @@ 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 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. +**Cutting an actual release is one command, run on the project owner's own +Apple Silicon Mac: `bun run tag `** (e.g. `bun run tag v1.2.3` or +`bun run tag 1.2.3` — either is normalized to a leading `v`). The owner +declined to install a CircleCI machine runner on their own Mac, so instead +of every target being built by CircleCI, `scripts/release.ts`'s +`RELEASE_TARGETS` now splits by `builtBy`: + +| Target | Built by | +|---|---| +| `bun-darwin-arm64` | `"local"` — `bun run tag`, on the owner's Mac | +| `bun-linux-x64` | `"circleci"` — CircleCI-hosted executor | +| `bun-linux-arm64` | `"circleci"` — CircleCI-hosted executor | +| `bun-windows-x64` | `"circleci"` — self-hosted runner, the owner's Windows box | + +`bun run tag ` (`scripts/tagRelease.ts`) does, in this exact +order — see that script's own TSDoc for the full reasoning behind the +order: + +1. **Preflight** — every check runs and must pass before anything below + mutates anything: host is `darwin`/`arm64`; `TECODE_RELEASE_TOKEN` is + set (see "Release token" below — **not** `GITHUB_TOKEN`); the git + working tree is clean; the current branch is `main` and matches + `origin/main` exactly (fetched first); the version argument is + well-formed; the tag doesn't already exist locally or on origin; and no + GitHub release (draft or published) already carries that `tag_name`. + Any failure prints exactly what's wrong and what to do — nothing runs + until all of them pass. +2. **Build** `bun-darwin-arm64` (reusing `scripts/release.ts`'s + `buildTarget`/`writeChecksumFile` — the size budget and `.sha256` + sibling are enforced the same way `bun run release` enforces them). + This runs first because it's the step most likely to fail, and a failed + build leaves nothing else behind to clean up. +3. **Create a DRAFT GitHub Release** for the version and upload the two + macOS assets, then verify both actually landed. +4. **Create and push the annotated git tag** — last, because it's the one + irreversible step: pushing it fires the CircleCI pipeline below + immediately, and a push cannot be un-fired the way a draft release can + simply be deleted. If step 3 already succeeded and this step fails, the + script prints exactly how to recover (the draft release to delete, or + the exact `git tag`/`git push` command to finish by hand). + +Once the tag is pushed, `.circleci/config.yml`'s `release` workflow builds +the three `builtBy: "circleci"` targets in parallel — one runner per +target, one of them self-hosted on the project owner's own Windows box +(see that config's own top-of-file comment for the full explanation) — +each invoking `bun run release `. Every target persists +its binary and checksum to a shared workspace for the `publish` job, which +finds the draft release `bun run tag` already created (by listing releases +and matching `tag_name` + `draft: true` — `GET /releases/tags/{tag}` does +NOT reliably return draft releases, so `publish` never uses it), uploads +its own three targets' assets on top of the one already there, verifies +the release holds all four binaries and four checksums, and only then +`PATCH`es it to `draft: false`. + +**Pushing a `v*` tag by hand, without running `bun run tag`, does NOT +produce a release — it produces a stuck pipeline.** `publish` looks for a +draft release matching the tag, finds none (only `bun run tag` ever +creates one), and fails loudly rather than creating a release itself — a +release `publish` created on its own would permanently be missing the +macOS binary, since nothing else in this pipeline builds +`bun-darwin-arm64`. If this happens, delete the tag and run `bun run tag +` from the owner's Mac instead. Only four of the six theoretically possible `darwin`/`linux`/`windows` × `x64`/`arm64` combinations are published: `bun-darwin-x64` (Intel macOS) @@ -72,27 +125,46 @@ 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 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`). +### Release token (`TECODE_RELEASE_TOKEN`) + +Both `bun run tag` (on the owner's Mac) and CircleCI's `publish` job need +their own GitHub credential to talk to the GitHub REST API — create the +draft release, upload assets, and flip it to published. Both read it from +an environment variable named **`TECODE_RELEASE_TOKEN`**, deliberately +**not** `GITHUB_TOKEN`: that name is common enough ambient convention (the +`gh` CLI, GitHub Actions runners, and other tools all read it) that it may +already be set on a given machine to a token with far broader scope than +this task needs, and there is no fallback to it — if `TECODE_RELEASE_TOKEN` +is unset, both `bun run tag` and CircleCI's `publish` job fail preflight +outright rather than silently using something else. + +**Minimum required scope, for both copies of this token**: a +**fine-grained** personal access token, "Repository access" limited to +**only `goofmint/tecode`**, with exactly one repository permission granted +— **Contents: Read and write** (GitHub files releases under Contents; this +is what lets the token create a draft release, upload assets to it, and +publish it). **A classic PAT is the wrong choice here** — its `repo` scope +reaches every repository the token's owner can access, plus issues, with +no way to narrow it down to just this one repository's releases. + +This token is used **only** for the GitHub REST API calls described above. +**Pushing the git tag itself uses the machine's ordinary `git` +credentials, not this token** — `TECODE_RELEASE_TOKEN` needs no git +push/write access at all. The two copies (the owner's Mac, and CircleCI's +project settings) are separate credentials in separate places — rotate or +revoke either independently of the other. ## Install 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. +13.2) — published as GitHub Release assets by `bun run tag` (the one +`builtBy: "local"` macOS binary) together with the CircleCI pipeline's +tag-triggered `publish` job (the three `builtBy: "circleci"` binaries) — +see "Release" above for the full split. 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) diff --git a/design.md b/design.md index 68d0d12..df3d244 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 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). +- 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 — 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). Each target's `ReleaseTarget.builtBy` records who actually builds it for a release, since the two pipelines that do are separate: `bun-linux-x64`/`bun-linux-arm64`/`bun-windows-x64` are `"circleci"`, built by `.circleci/config.yml`'s tag-triggered `release` workflow (one self-hosted, on the project owner's own Windows machine); `bun-darwin-arm64` is `"local"` — the owner declined to install a CircleCI machine runner on their own Mac, so a local command, `bun run tag ` (`scripts/tagRelease.ts`), builds that one target, creates a DRAFT GitHub Release with its two assets, and only then creates and pushes the `v*` tag that fires the CircleCI workflow for the other three; CircleCI's `publish` job finds that draft (by listing releases and matching `tag_name`+`draft`, since the tag-lookup endpoint doesn't reliably return drafts), adds its own three targets' assets, verifies the release holds all `RELEASE_TARGETS.length` binaries, and publishes. Pushing a `v*` tag by hand instead of running `bun run tag` does not produce a release — `publish` finds no matching draft and fails loudly, leaving a stuck pipeline rather than creating a release that would permanently omit the macOS binary. - 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 3d75ca0..0083e33 100644 --- a/docs/manual-release-verification.md +++ b/docs/manual-release-verification.md @@ -57,8 +57,12 @@ 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 the tag-triggered CircleCI release -pipeline, `.circleci/config.yml`, automates): +that platform — this is exactly what happens automatically for +`bun-linux-arm64`/`bun-windows-x64` in the tag-triggered CircleCI release +pipeline (`.circleci/config.yml`), and for `bun-darwin-arm64` in `bun run +tag` (`scripts/tagRelease.ts`, run by hand on the project owner's own +Apple Silicon Mac — see the README's "Release" section for why that one +target is built locally instead of by CircleCI)): 1. On a `darwin`/`arm64`, `linux`/`arm64`, or `windows`/`x64` machine with Bun installed, clone the repo and run `bun install` — this links that diff --git a/package.json b/package.json index 6ddc030..286ebf1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lint": "eslint .", "cli": "bun packages/cli/src/main.ts", "bench": "bun test packages/cli/src/typingBenchmark.test.ts", - "release": "bun scripts/release.ts" + "release": "bun scripts/release.ts", + "tag": "bun scripts/tagRelease.ts" }, "devDependencies": { "@eslint/js": "^9.19.0", diff --git a/requirements.md b/requirements.md index f194e29..109ddfe 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`, 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. +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. `bun-darwin-arm64` SHALL be built locally, by `bun run tag ` on the project owner's own Apple Silicon Mac, rather than by a CircleCI runner (the owner declined to install a CircleCI machine runner on that Mac); the other three targets SHALL continue to be built by CircleCI. `bun run tag` SHALL build its target, create a draft GitHub Release with its assets, and only then create and push the tag that triggers the rest of the pipeline; pushing a `v*` tag by any other means SHALL NOT produce a release — the pipeline SHALL fail loudly instead of publishing a release permanently missing the macOS binary. 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/githubRelease.test.ts b/scripts/githubRelease.test.ts new file mode 100644 index 0000000..3257afe --- /dev/null +++ b/scripts/githubRelease.test.ts @@ -0,0 +1,170 @@ +/** + * Unit tests for {@link githubRelease.ts}'s pure GitHub-Release selection + * and completeness logic — see that module's own TSDoc for why this exists + * as a separately-tested spec even though `.circleci/config.yml`'s + * `publish` job re-implements the same algorithm in bash (which has no + * import statement to reach these functions with). + */ + +import { describe, expect, test } from "bun:test"; +import { + checkAssetsComplete, + findDraftReleaseByTag, + findReleaseByTag, + parseGitHubRemote, + type GitHubReleaseSummary, +} from "./githubRelease"; + +const RELEASES: readonly GitHubReleaseSummary[] = [ + { id: 1, tag_name: "v0.9.0", draft: false }, + { id: 2, tag_name: "v1.0.0", draft: true }, + { id: 3, tag_name: "v1.0.0-rc1", draft: false }, +]; + +describe("findReleaseByTag", () => { + test("finds a published release by exact tag_name", () => { + expect(findReleaseByTag(RELEASES, "v0.9.0")).toEqual({ id: 1, tag_name: "v0.9.0", draft: false }); + }); + + test("finds a draft release by exact tag_name too — draft or published, either counts", () => { + expect(findReleaseByTag(RELEASES, "v1.0.0")).toEqual({ id: 2, tag_name: "v1.0.0", draft: true }); + }); + + test("returns undefined when no release matches the tag", () => { + expect(findReleaseByTag(RELEASES, "v2.0.0")).toBeUndefined(); + }); + + test("does not match on a substring/prefix of the tag", () => { + // v1.0.0-rc1 exists, but v1.0.0 must not match it - exact string only. + expect(findReleaseByTag([{ id: 9, tag_name: "v1.0.0-rc1", draft: false }], "v1.0.0")).toBeUndefined(); + }); + + test("empty release list never matches", () => { + expect(findReleaseByTag([], "v1.0.0")).toBeUndefined(); + }); +}); + +describe("findDraftReleaseByTag (what CircleCI's publish job selects)", () => { + test("finds the draft matching the tag", () => { + expect(findDraftReleaseByTag(RELEASES, "v1.0.0")).toEqual({ id: 2, tag_name: "v1.0.0", draft: true }); + }); + + test("does NOT return a published release sharing the tag", () => { + expect(findDraftReleaseByTag(RELEASES, "v0.9.0")).toBeUndefined(); + }); + + test("returns undefined when the tag has no release at all — the 'tag pushed without bun run tag' case", () => { + expect(findDraftReleaseByTag(RELEASES, "v3.0.0")).toBeUndefined(); + }); + + test("returns undefined for a tag whose only release is published, not draft", () => { + expect(findDraftReleaseByTag(RELEASES, "v1.0.0-rc1")).toBeUndefined(); + }); +}); + +describe("checkAssetsComplete", () => { + const expectedFour = ["tecode-darwin-arm64", "tecode-darwin-arm64.sha256", "tecode-linux-x64", "tecode-linux-x64.sha256"]; + + test("ok when every expected asset is present, in any order", () => { + const result = checkAssetsComplete( + ["tecode-linux-x64.sha256", "tecode-darwin-arm64", "tecode-linux-x64", "tecode-darwin-arm64.sha256"], + expectedFour, + ); + expect(result).toEqual({ ok: true, missing: [] }); + }); + + test("ok when actual has MORE than expected — extra assets are never flagged", () => { + const result = checkAssetsComplete([...expectedFour, "some-other-file.txt"], expectedFour); + expect(result.ok).toBe(true); + }); + + test("reports exactly which names are missing, in expected order", () => { + const result = checkAssetsComplete(["tecode-darwin-arm64", "tecode-darwin-arm64.sha256"], expectedFour); + expect(result).toEqual({ ok: false, missing: ["tecode-linux-x64", "tecode-linux-x64.sha256"] }); + }); + + test("a matching COUNT with the wrong names still fails — count alone is not enough", () => { + // Same count (4) as expectedFour, but two different files instead of + // tecode-linux-x64/.sha256 - the bug a naive count-only check misses. + const result = checkAssetsComplete( + ["tecode-darwin-arm64", "tecode-darwin-arm64.sha256", "tecode-windows-x64.exe", "tecode-windows-x64.exe.sha256"], + expectedFour, + ); + expect(result.ok).toBe(false); + expect(result.missing).toEqual(["tecode-linux-x64", "tecode-linux-x64.sha256"]); + }); + + test("empty expected list is trivially complete", () => { + expect(checkAssetsComplete([], [])).toEqual({ ok: true, missing: [] }); + }); + + test("empty actual list against a non-empty expected list is fully missing", () => { + expect(checkAssetsComplete([], ["a", "b"])).toEqual({ ok: false, missing: ["a", "b"] }); + }); +}); + +describe("parseGitHubRemote", () => { + // Regression: `ssh://git@github.com/owner/repo.git` is a form `git remote + // add` accepts and `git-clone(1)` documents, but it matched neither the + // https:// nor the scp-like pattern, so `bun run tag` exited before + // preflight for anyone whose origin was spelled this way. + test.each([ + ["ssh://git@github.com/goofmint/tecode.git", "goofmint", "tecode"], + ["ssh://git@github.com/goofmint/tecode", "goofmint", "tecode"], + ["ssh://github.com/goofmint/tecode.git", "goofmint", "tecode"], + ["ssh://git@github.com:22/goofmint/tecode.git", "goofmint", "tecode"], + ["ssh://git@github.com/goofmint/tecode.git/", "goofmint", "tecode"], + ])("parses the ssh:// URL form %s", (url, owner, repo) => { + expect(parseGitHubRemote(url)).toEqual({ owner, repo }); + }); + + test("still rejects an ssh:// URL pointing somewhere other than github.com", () => { + expect(parseGitHubRemote("ssh://git@gitlab.com/goofmint/tecode.git")).toBeUndefined(); + // Guards against a pattern loose enough to treat github.com as a mere + // substring of the host. + expect(parseGitHubRemote("ssh://git@github.com.evil.example/goofmint/tecode.git")).toBeUndefined(); + }); + + test("parses an HTTPS remote URL with .git suffix", () => { + expect(parseGitHubRemote("https://github.com/goofmint/tecode.git")).toEqual({ + owner: "goofmint", + repo: "tecode", + }); + }); + + test("parses an HTTPS remote URL without .git suffix", () => { + expect(parseGitHubRemote("https://github.com/goofmint/tecode")).toEqual({ + owner: "goofmint", + repo: "tecode", + }); + }); + + test("parses an SSH remote URL with .git suffix", () => { + expect(parseGitHubRemote("git@github.com:goofmint/tecode.git")).toEqual({ + owner: "goofmint", + repo: "tecode", + }); + }); + + test("parses an SSH remote URL without .git suffix", () => { + expect(parseGitHubRemote("git@github.com:goofmint/tecode")).toEqual({ + owner: "goofmint", + repo: "tecode", + }); + }); + + test("tolerates surrounding whitespace (e.g. trailing newline from `git remote get-url`)", () => { + expect(parseGitHubRemote("https://github.com/goofmint/tecode.git\n")).toEqual({ + owner: "goofmint", + repo: "tecode", + }); + }); + + test("returns undefined for a non-GitHub remote", () => { + expect(parseGitHubRemote("https://gitlab.com/goofmint/tecode.git")).toBeUndefined(); + }); + + test("returns undefined for a malformed URL", () => { + expect(parseGitHubRemote("not a url at all")).toBeUndefined(); + }); +}); diff --git a/scripts/githubRelease.ts b/scripts/githubRelease.ts new file mode 100644 index 0000000..02a51f7 --- /dev/null +++ b/scripts/githubRelease.ts @@ -0,0 +1,161 @@ +/** + * Pure GitHub-Release domain logic shared, in spirit, by two different + * runtimes that both need it: `scripts/tagRelease.ts` (`bun run tag`, this + * repo's own TypeScript, imports this module directly) and `.circleci/ + * config.yml`'s `publish` job (a shell script — bash has no import + * statement, so it re-implements the SAME algorithm in `python3 -c` + * snippets inline). Keeping the algorithm here, with its own direct unit + * tests, means the logic itself — not just its two independent + * implementations — has a spec a reader can check either implementation + * against; the shell copy is verified separately by actually executing it + * against a stubbed `curl` (see this change's own validation notes, not a + * source file). + * + * Everything in this module is a pure function over already-fetched data — + * no network, no filesystem, no `git`. That is deliberate: it is the one + * part of the local-macOS-release flow (design decision behind `bun run + * tag`) cheap and deterministic enough to test directly, matching this + * repo's `scripts/release.ts` precedent of separating "decide what to do" + * (pure, tested here) from "actually do it" (impure, exercised for real + * only by manual/integration verification). + */ + +/** The handful of GitHub Release API fields this module's functions + * actually read — a real API response carries many more, but narrowing to + * exactly these (house `Pick<>`-narrowing convention, applied to a plain + * data shape rather than a service interface) keeps every test fixture + * below small and honest about what this code depends on. */ +export interface GitHubReleaseSummary { + readonly id: number; + readonly tag_name: string; + readonly draft: boolean; +} + +/** + * Find ANY release — draft or published — whose `tag_name` matches `tag`. + * This is `scripts/tagRelease.ts`'s own preflight check 8 ("no GitHub + * release already exists with that `tag_name`, draft or published"): a + * second `bun run tag` for a version that already shipped (or that has an + * abandoned draft sitting from a previous failed attempt) must not race or + * silently create a duplicate. + * + * Deliberately does NOT use `GET /repos/{owner}/{repo}/releases/tags/ + * {tag}` — that endpoint does not reliably return draft releases (observed + * behavior of the GitHub REST API, not documented reliably either way), so + * both this function and {@link findDraftReleaseByTag} below are written + * to search a full `GET /releases?per_page=100` listing instead, matching + * exactly what `.circleci/config.yml`'s `publish` job does in bash for the + * identical reason. + */ +export function findReleaseByTag( + releases: readonly GitHubReleaseSummary[], + tag: string, +): GitHubReleaseSummary | undefined { + return releases.find((release) => release.tag_name === tag); +} + +/** + * Find the DRAFT release `bun run tag` created for `tag` — the exact + * selection `.circleci/config.yml`'s `publish` job needs (its own + * top-of-file comment and this module's own TSDoc explain why a listing + * search, not the `/releases/tags/{tag}` endpoint). Requires BOTH + * `tag_name === tag` AND `draft === true`: a published release sharing the + * tag (should not normally happen, since {@link findReleaseByTag}'s + * preflight check blocks a second `bun run tag` for an already-published + * tag) must never be silently treated as the draft to attach assets to. + * + * `undefined` is `publish`'s "the tag was pushed without `bun run tag`" + * signal (Req: "fail loudly ... do not fall back to creating a release") — + * this function itself makes no such decision, it only reports what it + * found. + */ +export function findDraftReleaseByTag( + releases: readonly GitHubReleaseSummary[], + tag: string, +): GitHubReleaseSummary | undefined { + return releases.find((release) => release.tag_name === tag && release.draft === true); +} + +/** {@link checkAssetsComplete}'s verdict — `ok` is `true` only when every + * name in `expected` appears (by exact string) in `actual`; `missing` + * lists, in `expected`'s own order, every one that does not (empty when + * `ok`). Extra assets in `actual` beyond `expected` are never flagged — + * this check answers "is everything we need here", not "is there anything + * unexpected here". */ +export interface AssetCompletenessResult { + readonly ok: boolean; + readonly missing: readonly string[]; +} + +/** + * Confirm a release's actual asset names cover every expected one — the + * "asset-completeness check" both `bun run tag` conceptually relies on + * (implicitly: it uploads exactly the 2 macOS assets it just built, and + * this function is what proves both landed) and `.circleci/config.yml`'s + * `publish` job needs explicitly (Req: "verifies the release now holds all + * four binaries and all four checksums ... and only then PATCHes + * `draft:false`" — `publish`'s shell implementation re-derives the same + * two counts with its own `python3 -c` snippets, since it has no import + * statement to reach this function with). + * + * Exact-string membership, not count comparison: two releases can each + * have "4 binaries and 4 checksums" while actually missing one platform's + * pair and double-uploading another's, if this only compared counts. This + * function checks names instead — deliberately more precise than what the + * shell copy actually implements (a count comparison, per Req's own + * wording of that check) precisely because it exists as this algorithm's + * spec: the shell version is the "acceptable, simple approximation of + * this" that a comment can point back here to justify. + */ +export function checkAssetsComplete( + actualAssetNames: readonly string[], + expectedAssetNames: readonly string[], +): AssetCompletenessResult { + const actual = new Set(actualAssetNames); + const missing = expectedAssetNames.filter((name) => !actual.has(name)); + return { ok: missing.length === 0, missing }; +} + +/** + * Parse `owner`/`repo` out of a `git remote get-url origin` value — + * `bun run tag` needs these to build the GitHub API base URL + * (`https://api.github.com/repos//`) and has no other source + * for them (unlike CircleCI's `publish` job, which gets + * `CIRCLE_PROJECT_USERNAME`/`CIRCLE_PROJECT_REPONAME` from its own + * environment). Handles both remote URL shapes `git remote get-url` can + * return for a GitHub origin: + * + * - HTTPS: `https://github.com//.git` (or without the + * trailing `.git` — GitHub accepts clones either way, so both must + * parse identically). + * - SSH, scp-like: `git@github.com:/.git` (same optional + * `.git`). This is the form GitHub's own UI offers. + * - SSH, URL form: `ssh://git@github.com//.git`, with an + * optional `@` and an optional `:`. Git documents this + * alongside the scp-like form and `git remote add` accepts it, so an + * `origin` set up this way is not exotic — and `bun run tag` refusing to + * run against it would be a pure accident of which spellings this + * function happened to cover. + * + * Returns `undefined` for anything else (a non-GitHub remote, a malformed + * URL) — `bun run tag`'s preflight surfaces that as its own actionable + * failure rather than this function throwing. + */ +export function parseGitHubRemote(remoteUrl: string): { owner: string; repo: string } | undefined { + const trimmed = remoteUrl.trim(); + const patterns = [ + // https://github.com//[.git] + /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/, + // git@github.com:/[.git] + /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/, + // ssh://[@]github.com[:]//[.git] + /^ssh:\/\/(?:[^@/]+@)?github\.com(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/, + ]; + for (const pattern of patterns) { + const match = pattern.exec(trimmed); + if (match) { + return { owner: match[1]!, repo: match[2]! }; + } + } + return undefined; +} diff --git a/scripts/release.test.ts b/scripts/release.test.ts index 2734782..eca676e 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -11,12 +11,15 @@ * 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 + * Also covers the two cross-file invariants 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. + * `PUBLISH_EXPECTED_CI_BINARIES` (how many of {@link RELEASE_TARGETS} + * CircleCI's own build jobs produce — 3, since `bun-darwin-arm64` moved to + * `builtBy: "local"`) and `PUBLISH_EXPECTED_RELEASE_BINARIES` (how many + * the finished release must hold — {@link RELEASE_TARGETS}`.length`, 4) + * rather than reading {@link RELEASE_TARGETS} itself (a shell script has + * no import statement), so nothing stops either from drifting except this + * test — see the describe block below. */ import { describe, expect, test } from "bun:test"; @@ -45,13 +48,18 @@ describe("RELEASE_TARGETS (Req 13.2's published 4-target matrix)", () => { // `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" }, + { bunTarget: "bun-darwin-arm64", platform: "darwin", arch: "arm64", builtBy: "local" }, + { bunTarget: "bun-linux-x64", platform: "linux", arch: "x64", builtBy: "circleci" }, + { bunTarget: "bun-linux-arm64", platform: "linux", arch: "arm64", builtBy: "circleci" }, + { bunTarget: "bun-windows-x64", platform: "windows", arch: "x64", builtBy: "circleci" }, ]); }); + test("exactly one target is built locally (bun-darwin-arm64, by `bun run tag`)", () => { + const local = RELEASE_TARGETS.filter((t) => t.builtBy === "local"); + expect(local.map((t) => t.bunTarget)).toEqual(["bun-darwin-arm64"]); + }); + 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"); @@ -65,7 +73,7 @@ describe("RELEASE_TARGETS (Req 13.2's published 4-target matrix)", () => { }); }); -describe("PUBLISH_EXPECTED_BINARIES (.circleci/config.yml <-> RELEASE_TARGETS invariant)", () => { +describe("publish job env <-> RELEASE_TARGETS invariants (.circleci/config.yml <-> scripts/release.ts)", () => { /** 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, @@ -75,7 +83,14 @@ describe("PUBLISH_EXPECTED_BINARIES (.circleci/config.yml <-> RELEASE_TARGETS in run?: { environment?: Record }; } - test("the publish job's expected binary count equals RELEASE_TARGETS.length", async () => { + /** Parses `.circleci/config.yml` with `Bun.YAML.parse` (never a regex — + * a regex over YAML has already proven fragile once in this repo's + * history, per this file's own precedent) and returns the numeric value + * of the first `publish` job `run` step whose `environment` carries + * `key`. 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. */ + async function readPublishEnv(key: string): Promise { const configPath = resolve(import.meta.dir, "..", ".circleci", "config.yml"); const configText = await readFile(configPath, "utf8"); const config = Bun.YAML.parse(configText) as { @@ -84,16 +99,58 @@ describe("PUBLISH_EXPECTED_BINARIES (.circleci/config.yml <-> RELEASE_TARGETS in const publishSteps = config.jobs.publish.steps; const stepWithEnv = publishSteps.find( - (step): step is CircleCiRunStep => - typeof step === "object" && step.run?.environment?.PUBLISH_EXPECTED_BINARIES !== undefined, + (step): step is CircleCiRunStep => typeof step === "object" && step.run?.environment?.[key] !== undefined, ); expect(stepWithEnv).toBeDefined(); + return stepWithEnv!.run!.environment![key]; + } + + /** Both env vars are YAML folded scalars (`>-`), so they reach the shell + * as one whitespace-separated string — exactly how the `publish` job's + * `printf '%s\n' $VAR` consumes them (deliberately unquoted, so the + * shell word-splits). Split the same way here, rather than on `" "` + * specifically, so a reflow of the YAML block cannot change what this + * test compares. */ + function splitAssetList(raw: string): readonly string[] { + return raw.trim().split(/\s+/); + } + + /** The binary and checksum file names `targets` produce, derived from + * {@link binaryFileName} rather than restated — a rename there has to + * reach `.circleci/config.yml` or these tests fail, which is the whole + * point of pinning names instead of a count. */ + function expectedAssetNames(targets: readonly ReleaseTarget[]): readonly string[] { + return targets.flatMap((t) => [binaryFileName(t), `${binaryFileName(t)}.sha256`]); + } - // 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); + test("PUBLISH_EXPECTED_CI_ASSETS is exactly what the builtBy:\"circleci\" targets produce", async () => { + // Since bun-darwin-arm64 moved to `builtBy: "local"` (built by `bun run + // tag` on the owner's Mac, never by a CircleCI job), the workspace + // `publish` collects from ITS OWN build jobs holds only the + // `builtBy: "circleci"` targets — 3 of the 4. This is what `publish`'s + // first guard checks BEFORE it ever talks to the GitHub API. + const declared = splitAssetList(await readPublishEnv("PUBLISH_EXPECTED_CI_ASSETS")); + const derived = expectedAssetNames(RELEASE_TARGETS.filter((t) => t.builtBy === "circleci")); + expect([...declared].sort()).toEqual([...derived].sort()); + }); + + test("PUBLISH_EXPECTED_RELEASE_ASSETS is exactly what ALL of RELEASE_TARGETS produces", async () => { + // The SEPARATE list `publish` checks against the finished GitHub + // Release, after uploading its own 6 assets on top of the 2 `bun run + // tag` already put there. Names, not a count: the macOS pair arrives + // from a different machine at a different time, so only matching names + // establishes that it actually arrived. + const declared = splitAssetList(await readPublishEnv("PUBLISH_EXPECTED_RELEASE_ASSETS")); + const derived = expectedAssetNames(RELEASE_TARGETS); + expect([...declared].sort()).toEqual([...derived].sort()); + }); + + test("the two lists differ by exactly the locally-built target's assets", () => { + // Guards the split itself: if every target were marked `"circleci"`, + // or the macOS one silently rejoined the CI matrix, both lists above + // would still each be self-consistent while this relationship broke. + const local = RELEASE_TARGETS.filter((t) => t.builtBy === "local"); + expect(expectedAssetNames(local)).toEqual(["tecode-darwin-arm64", "tecode-darwin-arm64.sha256"]); }); }); @@ -109,15 +166,15 @@ describe("SIZE_LIMIT_BYTES (Req 13.2's 120 MB budget)", () => { describe("binaryFileName", () => { test("appends .exe only for windows targets", () => { - expect(binaryFileName({ bunTarget: "bun-linux-x64", platform: "linux", arch: "x64" })).toBe( + expect(binaryFileName({ bunTarget: "bun-linux-x64", platform: "linux", arch: "x64", builtBy: "circleci" })).toBe( "tecode-linux-x64", ); - expect(binaryFileName({ bunTarget: "bun-darwin-arm64", platform: "darwin", arch: "arm64" })).toBe( - "tecode-darwin-arm64", - ); - expect(binaryFileName({ bunTarget: "bun-windows-x64", platform: "windows", arch: "x64" })).toBe( - "tecode-windows-x64.exe", - ); + expect( + binaryFileName({ bunTarget: "bun-darwin-arm64", platform: "darwin", arch: "arm64", builtBy: "local" }), + ).toBe("tecode-darwin-arm64"); + expect( + binaryFileName({ bunTarget: "bun-windows-x64", platform: "windows", arch: "x64", builtBy: "circleci" }), + ).toBe("tecode-windows-x64.exe"); }); }); diff --git a/scripts/release.ts b/scripts/release.ts index 1e4cb94..c618367 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -18,6 +18,12 @@ * 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). + * `bun-darwin-arm64` (the one {@link ReleaseTarget.builtBy} `"local"` + * target) can still be built directly this way for a local smoke-test, but + * the actual release flow builds it as one step of `bun run tag` + * (`scripts/tagRelease.ts`), never as a standalone `bun run release + * bun-darwin-arm64` invocation — this script has no knowledge of releases, + * tags, or the GitHub API at all, only of building and checksumming. * * ## Why there is no extra bundler config for embedding * @@ -106,12 +112,19 @@ * * **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 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. + * links natively and the dynamic import above resolves normally. For the + * three {@link ReleaseTarget.builtBy} `"circleci"` targets that is 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 (one of the three + * self-hosted, the owner's Windows box — see that config's own comments) + * invokes this script with just its own target name. The fourth, + * `builtBy: "local"` target (`bun-darwin-arm64`) is instead built by + * `bun run tag` (`scripts/tagRelease.ts`) on the owner's own Apple Silicon + * Mac — the owner declined to install a CircleCI machine runner there, so + * this one target's build, its release upload, AND the tag push that fires + * CircleCI for the other three all happen locally in one command. See + * `scripts/tagRelease.ts`'s own TSDoc for the full flow. * * ## Two targets were dropped, not just left off this machine * @@ -126,12 +139,13 @@ * - **`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-darwin-arm64`, built locally by `bun run tag` — see + * {@link ReleaseTarget.builtBy}); 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. + * Windows machine is x64 (`bun-windows-x64`, self-hosted on CircleCI); + * 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 @@ -178,6 +192,32 @@ export interface ReleaseTarget { readonly bunTarget: string; readonly platform: "darwin" | "linux" | "windows"; readonly arch: "x64" | "arm64"; + /** + * WHO builds this target and pushes it into the release, not just which + * machine's architecture it needs (every target still needs a runner of + * its own platform — see this module's "Why this machine cannot produce + * even the four remaining binaries" — this field is about which + * *pipeline* invokes that build): + * + * - `"local"` — built on the project owner's own Apple Silicon Mac by + * `bun run tag` (`scripts/tagRelease.ts`), which also creates the draft + * GitHub Release and uploads this target's two assets, all BEFORE the + * tag that fires CircleCI is pushed. The owner declined to install a + * CircleCI machine runner on their Mac, so this is the one target + * CircleCI's `release` workflow never builds at all — no + * `build-darwin-arm64` job exists. + * - `"circleci"` — built by a job in `.circleci/config.yml`'s `release` + * workflow, on either a CircleCI-hosted executor (`bun-linux-x64`, + * `bun-linux-arm64`) or the owner's self-hosted Windows runner + * (`bun-windows-x64`). + * + * `publish` (CircleCI) finds the draft `bun run tag` already created, + * uploads only the `"circleci"` targets' assets to it, verifies the + * release holds all {@link RELEASE_TARGETS}`.length` binaries (its own + * three plus the local one already there), and only then publishes — + * see `.circleci/config.yml`'s `publish` job and `scripts/tagRelease.ts`. + */ + readonly builtBy: "local" | "circleci"; } /** The real, published 4-target release matrix (Req 13.2, design.md §17) — @@ -190,12 +230,16 @@ export interface ReleaseTarget { * 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. */ + * independently. Each entry's {@link ReleaseTarget.builtBy} records WHO + * builds it — `bun-darwin-arm64` is `"local"` (the owner's Mac, via + * `bun run tag`), the other three are `"circleci"` (see that field's own + * TSDoc for why this split exists and how the two pipelines meet at one + * GitHub Release). */ export const RELEASE_TARGETS: readonly ReleaseTarget[] = [ - { 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-darwin-arm64", platform: "darwin", arch: "arm64", builtBy: "local" }, + { bunTarget: "bun-linux-x64", platform: "linux", arch: "x64", builtBy: "circleci" }, + { bunTarget: "bun-linux-arm64", platform: "linux", arch: "arm64", builtBy: "circleci" }, + { bunTarget: "bun-windows-x64", platform: "windows", arch: "x64", builtBy: "circleci" }, ]; /** diff --git a/scripts/tagRelease.test.ts b/scripts/tagRelease.test.ts new file mode 100644 index 0000000..7a328eb --- /dev/null +++ b/scripts/tagRelease.test.ts @@ -0,0 +1,750 @@ +/** + * Unit tests for `scripts/tagRelease.ts` — `bun run tag`'s pure decision + * logic (version normalization, all 8 preflight checks, and + * {@link evaluatePreflight}'s composition of them) plus, via hand-rolled + * fakes for {@link GitPort}/{@link GitHubPort}/`buildTarget`/ + * `writeChecksumFile`, the orchestrator's own sequencing: a failing + * preflight check mutates nothing, and a build failure happens before any + * release is created and before any tag is pushed (this task's own + * completion requirements). No mocking library — every fake below is a + * plain object recording calls into an array the test asserts on + * afterward, matching this codebase's house convention. + */ + +import { describe, expect, test } from "bun:test"; +import type { BuildOutcome, ReleaseTarget } from "./release"; +import { + checkReleaseTokenSet, + checkHostIsMacSilicon, + checkMainUpToDateWithOrigin, + checkNoExistingRelease, + checkOnMainBranch, + checkTagNotTaken, + checkWorkingTreeClean, + createTagReleaseRunner, + evaluatePreflight, + gatherPreflightInputs, + LOCAL_TARGET, + normalizeVersionArg, + type Fetched, + type GitHubPort, + type GitPort, + type PreflightInputs, + type TagReleaseDeps, +} from "./tagRelease"; +import type { GitHubReleaseSummary } from "./githubRelease"; + +/* -------------------------------------------------------------------- */ +/* normalizeVersionArg */ +/* -------------------------------------------------------------------- */ + +describe("normalizeVersionArg", () => { + // Regression: these are built entirely from characters the allowed class + // permits, so a charset-only check waved them through — and `git tag` + // then rejected them in step 4, after a real compile and a draft release + // full of uploaded assets already existed. + test.each(["1..2", "v1..2", "1.2.", "v1.2.", "1.2.lock", "v1.2.lock"])( + "rejects %s — git itself will not accept that ref name", + (raw) => { + const result = normalizeVersionArg(raw); + expect(result.ok).toBe(false); + expect(result.ok === false && result.error).toContain("not a valid git tag name"); + }, + ); + + test("does not over-reject: a dot-leading body is a valid ref once prefixed with v", () => { + // `check-ref-format` forbids a ref COMPONENT beginning with ".", and the + // component here is always `v` — so `.1` is fine, unlike the cases + // above. Pinned so a future tightening cannot quietly forbid it. + expect(normalizeVersionArg("v.1")).toEqual({ ok: true, tag: "v.1" }); + }); + + test("accepts a bare version and prefixes it with v", () => { + expect(normalizeVersionArg("1.2.3")).toEqual({ ok: true, tag: "v1.2.3" }); + }); + + test("accepts an already-v-prefixed version unchanged", () => { + expect(normalizeVersionArg("v1.2.3")).toEqual({ ok: true, tag: "v1.2.3" }); + }); + + test("accepts a prerelease/build-metadata suffix (+, -, . all allowed)", () => { + expect(normalizeVersionArg("v1.2.3-rc.1+build.7")).toEqual({ ok: true, tag: "v1.2.3-rc.1+build.7" }); + }); + + test("rejects an empty string", () => { + const result = normalizeVersionArg(""); + expect(result.ok).toBe(false); + }); + + test("rejects whitespace-only input", () => { + const result = normalizeVersionArg(" "); + expect(result.ok).toBe(false); + }); + + test("rejects a version containing a character outside [0-9A-Za-z.+-] after the v", () => { + const result = normalizeVersionArg("v1.2.3!"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("v1.2.3!"); + }); + + test("rejects a version containing a space", () => { + expect(normalizeVersionArg("v1.2 .3").ok).toBe(false); + }); + + test("rejects a bare 'v' with nothing after it", () => { + expect(normalizeVersionArg("v").ok).toBe(false); + }); + + test("rejects a slash (path-traversal-shaped ref)", () => { + expect(normalizeVersionArg("v1/2/3").ok).toBe(false); + }); + + test("trims surrounding whitespace before validating", () => { + expect(normalizeVersionArg(" v1.2.3 ")).toEqual({ ok: true, tag: "v1.2.3" }); + }); +}); + +/* -------------------------------------------------------------------- */ +/* Individual preflight checks */ +/* -------------------------------------------------------------------- */ + +function ok(value: T): Fetched { + return { ok: true, value }; +} +function err(error: string): Fetched { + return { ok: false, error }; +} + +describe("checkHostIsMacSilicon", () => { + test("passes on darwin/arm64", () => { + expect(checkHostIsMacSilicon("darwin", "arm64").ok).toBe(true); + }); + test("fails on darwin/x64 (Intel Mac) with a specific message", () => { + const result = checkHostIsMacSilicon("darwin", "x64"); + expect(result.ok).toBe(false); + expect(result.message).toContain("darwin/x64"); + expect(result.message).toContain("Apple Silicon"); + }); + test("fails on linux/x64", () => { + expect(checkHostIsMacSilicon("linux", "x64").ok).toBe(false); + }); +}); + +describe("checkReleaseTokenSet", () => { + test("passes when TECODE_RELEASE_TOKEN is a non-empty string", () => { + expect(checkReleaseTokenSet({ TECODE_RELEASE_TOKEN: "github_pat_abc123" }).ok).toBe(true); + }); + test("fails when TECODE_RELEASE_TOKEN is unset", () => { + expect(checkReleaseTokenSet({}).ok).toBe(false); + }); + test("fails when TECODE_RELEASE_TOKEN is an empty string", () => { + expect(checkReleaseTokenSet({ TECODE_RELEASE_TOKEN: "" }).ok).toBe(false); + }); + test("fails when TECODE_RELEASE_TOKEN is whitespace-only", () => { + expect(checkReleaseTokenSet({ TECODE_RELEASE_TOKEN: " " }).ok).toBe(false); + }); + test("does NOT fall back to GITHUB_TOKEN when TECODE_RELEASE_TOKEN is unset", () => { + expect(checkReleaseTokenSet({ GITHUB_TOKEN: "ghp_some_broad_ambient_token" }).ok).toBe(false); + }); +}); + +describe("checkWorkingTreeClean", () => { + test("passes when git status --porcelain is empty", () => { + expect(checkWorkingTreeClean(ok("")).ok).toBe(true); + }); + test("fails when there is uncommitted output, and includes it in the message", () => { + const result = checkWorkingTreeClean(ok(" M scripts/tagRelease.ts\n")); + expect(result.ok).toBe(false); + expect(result.message).toContain("scripts/tagRelease.ts"); + }); + test("fails when the underlying git call itself failed", () => { + const result = checkWorkingTreeClean(err("git not found")); + expect(result.ok).toBe(false); + expect(result.message).toContain("git not found"); + }); +}); + +describe("checkOnMainBranch", () => { + test("passes on main", () => { + expect(checkOnMainBranch(ok("main")).ok).toBe(true); + }); + test("fails on a feature branch, naming it", () => { + const result = checkOnMainBranch(ok("feature/local-macos-tag-release")); + expect(result.ok).toBe(false); + expect(result.message).toContain("feature/local-macos-tag-release"); + }); +}); + +describe("checkMainUpToDateWithOrigin", () => { + test("passes when local and remote SHAs match", () => { + expect(checkMainUpToDateWithOrigin(ok("abc123"), ok("abc123")).ok).toBe(true); + }); + test("fails when they differ", () => { + const result = checkMainUpToDateWithOrigin(ok("abc123"), ok("def456")); + expect(result.ok).toBe(false); + }); + test("fails when fetching origin/main itself failed", () => { + const result = checkMainUpToDateWithOrigin(ok("abc123"), err("network unreachable")); + expect(result.ok).toBe(false); + expect(result.message).toContain("network unreachable"); + }); +}); + +describe("checkTagNotTaken", () => { + test("passes when the tag exists neither locally nor remotely", () => { + expect(checkTagNotTaken("v1.0.0", ok(false), ok(false)).ok).toBe(true); + }); + test("fails when the tag exists locally", () => { + const result = checkTagNotTaken("v1.0.0", ok(true), ok(false)); + expect(result.ok).toBe(false); + expect(result.message).toContain("locally"); + }); + test("fails when the tag exists on origin", () => { + const result = checkTagNotTaken("v1.0.0", ok(false), ok(true)); + expect(result.ok).toBe(false); + expect(result.message).toContain("origin"); + }); +}); + +describe("checkNoExistingRelease", () => { + test("passes when no release matches the tag", () => { + expect(checkNoExistingRelease("v1.0.0", ok([])).ok).toBe(true); + }); + test("fails when a draft release already carries the tag", () => { + const releases: GitHubReleaseSummary[] = [{ id: 7, tag_name: "v1.0.0", draft: true }]; + const result = checkNoExistingRelease("v1.0.0", ok(releases)); + expect(result.ok).toBe(false); + expect(result.message).toContain("draft"); + }); + test("fails when a published release already carries the tag", () => { + const releases: GitHubReleaseSummary[] = [{ id: 7, tag_name: "v1.0.0", draft: false }]; + const result = checkNoExistingRelease("v1.0.0", ok(releases)); + expect(result.ok).toBe(false); + expect(result.message).toContain("published"); + }); +}); + +/* -------------------------------------------------------------------- */ +/* evaluatePreflight — composition */ +/* -------------------------------------------------------------------- */ + +function allGoodInputs(overrides: Partial = {}): PreflightInputs { + return { + platform: "darwin", + arch: "arm64", + env: { TECODE_RELEASE_TOKEN: "github_pat_x" }, + rawVersion: "v1.2.3", + workingTreeStatus: ok(""), + currentBranch: ok("main"), + localMainSha: ok("aaa"), + remoteMainSha: ok("aaa"), + tagExistsLocally: ok(false), + tagExistsRemotely: ok(false), + existingReleases: ok([]), + ...overrides, + }; +} + +describe("evaluatePreflight", () => { + test("passes when every input is clean, and returns the normalized tag", () => { + const result = evaluatePreflight(allGoodInputs()); + expect(result.ok).toBe(true); + expect(result.tag).toBe("v1.2.3"); + expect(result.checks).toHaveLength(8); + expect(result.checks.every((c) => c.ok)).toBe(true); + }); + + test("fails overall when exactly one check fails, but still runs (reports) all 8", () => { + const result = evaluatePreflight(allGoodInputs({ currentBranch: ok("feature/x") })); + expect(result.ok).toBe(false); + expect(result.checks).toHaveLength(8); + const failing = result.checks.filter((c) => !c.ok); + expect(failing).toHaveLength(1); + expect(failing[0]!.name).toBe("current branch is main"); + }); + + test("reports every failing check at once when multiple checks fail (not just the first)", () => { + const result = evaluatePreflight( + allGoodInputs({ + platform: "linux", + currentBranch: ok("dev"), + workingTreeStatus: ok(" M foo.ts"), + }), + ); + expect(result.ok).toBe(false); + const failingNames = result.checks.filter((c) => !c.ok).map((c) => c.name); + expect(failingNames).toEqual( + expect.arrayContaining(["host is darwin/arm64", "current branch is main", "git working tree is clean"]), + ); + expect(failingNames.length).toBeGreaterThanOrEqual(3); + }); + + test("an invalid version fails overall and marks the two tag-dependent checks as skipped, not silently omitted", () => { + const result = evaluatePreflight(allGoodInputs({ rawVersion: "not a version" })); + expect(result.ok).toBe(false); + expect(result.tag).toBeUndefined(); + expect(result.checks).toHaveLength(8); + const tagCheck = result.checks.find((c) => c.name === "tag does not already exist")!; + const releaseCheck = result.checks.find((c) => c.name === "no GitHub release exists for this version")!; + expect(tagCheck.ok).toBe(false); + expect(tagCheck.message).toContain("skipped"); + expect(releaseCheck.ok).toBe(false); + expect(releaseCheck.message).toContain("skipped"); + }); + + test("every check fails together when every input is bad", () => { + const result = evaluatePreflight({ + platform: "linux", + arch: "x64", + env: {}, + rawVersion: "bad version!", + workingTreeStatus: ok(" M x"), + currentBranch: ok("dev"), + localMainSha: ok("aaa"), + remoteMainSha: ok("bbb"), + tagExistsLocally: ok(true), + tagExistsRemotely: ok(true), + existingReleases: ok([{ id: 1, tag_name: "v1.2.3", draft: false }]), + }); + expect(result.ok).toBe(false); + expect(result.checks.every((c) => !c.ok)).toBe(true); + }); +}); + +/* -------------------------------------------------------------------- */ +/* gatherPreflightInputs — one flaky fetch does not hide the rest */ +/* -------------------------------------------------------------------- */ + +describe("gatherPreflightInputs", () => { + function fakeGit(overrides: Partial = {}): Pick< + GitPort, + "statusPorcelain" | "currentBranch" | "fetchOrigin" | "revParse" | "tagExistsLocally" | "tagExistsRemotely" + > { + return { + statusPorcelain: async () => "", + currentBranch: async () => "main", + fetchOrigin: async () => {}, + revParse: async (ref: string) => (ref === "main" ? "sha-local" : "sha-remote"), + tagExistsLocally: async () => false, + tagExistsRemotely: async () => false, + ...overrides, + }; + } + function fakeGithub(overrides: Partial> = {}): Pick { + return { listReleases: async () => [], ...overrides }; + } + + // Regression: `git fetch` used to be best-effort, its failure discarded + // on the theory that a bad fetch would surface as a stale-or-missing + // origin/main. It does not: `git rev-parse origin/main` resolves the + // STALE remote-tracking ref perfectly well, and a stale ref is the one + // most likely to equal local `main` — so the "is main up to date?" check + // passed exactly when it mattered least, and a release could be tagged + // at a commit behind the real origin/main. + test("a failed fetch fails remoteMainSha instead of trusting the stale ref", async () => { + const inputs = await gatherPreflightInputs( + { + platform: "darwin", + arch: "arm64", + env: { TECODE_RELEASE_TOKEN: "t" }, + git: fakeGit({ + fetchOrigin: async () => { + throw new Error("network unreachable"); + }, + // The stale ref still resolves, and still matches local main — + // precisely the situation the old code called "up to date". + revParse: async () => "sha-identical", + }), + github: fakeGithub(), + }, + "v1.0.0", + ); + expect(inputs.localMainSha).toEqual({ ok: true, value: "sha-identical" }); + expect(inputs.remoteMainSha.ok).toBe(false); + expect(inputs.remoteMainSha.ok === false && inputs.remoteMainSha.error).toContain("network unreachable"); + + // And the check built on it must actually fail, not just carry an error. + expect(checkMainUpToDateWithOrigin(inputs.localMainSha, inputs.remoteMainSha).ok).toBe(false); + }); + + test("wires every field through on the happy path", async () => { + const inputs = await gatherPreflightInputs( + { platform: "darwin", arch: "arm64", env: { TECODE_RELEASE_TOKEN: "t" }, git: fakeGit(), github: fakeGithub() }, + "v1.0.0", + ); + expect(inputs.workingTreeStatus).toEqual({ ok: true, value: "" }); + expect(inputs.currentBranch).toEqual({ ok: true, value: "main" }); + expect(inputs.localMainSha).toEqual({ ok: true, value: "sha-local" }); + expect(inputs.remoteMainSha).toEqual({ ok: true, value: "sha-remote" }); + expect(inputs.tagExistsLocally).toEqual({ ok: true, value: false }); + expect(inputs.tagExistsRemotely).toEqual({ ok: true, value: false }); + expect(inputs.existingReleases).toEqual({ ok: true, value: [] }); + }); + + test("a failing git call becomes a Fetched error, not a thrown exception, and other fields still resolve", async () => { + const inputs = await gatherPreflightInputs( + { + platform: "darwin", + arch: "arm64", + env: {}, + git: fakeGit({ statusPorcelain: async () => { throw new Error("git status exploded"); } }), + github: fakeGithub(), + }, + "v1.0.0", + ); + expect(inputs.workingTreeStatus).toEqual({ ok: false, error: "git status exploded" }); + // Everything else still resolved normally. + expect(inputs.currentBranch).toEqual({ ok: true, value: "main" }); + }); + + test("skips querying tag existence / releases when the version is malformed", async () => { + let releasesCalled = false; + let tagLocalCalled = false; + const inputs = await gatherPreflightInputs( + { + platform: "darwin", + arch: "arm64", + env: {}, + git: fakeGit({ + tagExistsLocally: async () => { + tagLocalCalled = true; + return false; + }, + }), + github: fakeGithub({ + listReleases: async () => { + releasesCalled = true; + return []; + }, + }), + }, + "not-a-valid-version!", + ); + expect(releasesCalled).toBe(false); + expect(tagLocalCalled).toBe(false); + expect(inputs.existingReleases).toEqual({ ok: true, value: [] }); + expect(inputs.tagExistsLocally).toEqual({ ok: true, value: false }); + }); +}); + +/* -------------------------------------------------------------------- */ +/* createTagReleaseRunner — orchestration / sequencing */ +/* -------------------------------------------------------------------- */ + +/** Records every call made to it, in order, as a flat string — the + * house "hand-rolled fake, no mocking library" convention. Every test + * below asserts on this call log directly rather than on a mock + * framework's spy API. */ +function makeCallLog(): { calls: string[]; push: (entry: string) => void } { + const calls: string[] = []; + return { calls, push: (entry: string) => calls.push(entry) }; +} + +function goodGit(log: { push: (s: string) => void }): Pick< + GitPort, + "statusPorcelain" | "currentBranch" | "fetchOrigin" | "revParse" | "tagExistsLocally" | "tagExistsRemotely" | "createAnnotatedTag" | "pushTag" +> { + return { + statusPorcelain: async () => "", + currentBranch: async () => "main", + fetchOrigin: async () => { + log.push("git.fetchOrigin"); + }, + revParse: async (ref: string) => (ref === "main" ? "sha1" : "sha1"), + tagExistsLocally: async () => false, + tagExistsRemotely: async () => false, + createAnnotatedTag: async (tag: string) => { + log.push(`git.createAnnotatedTag(${tag})`); + }, + pushTag: async (tag: string) => { + log.push(`git.pushTag(${tag})`); + }, + }; +} + +function goodGithub( + log: { push: (s: string) => void }, + overrides: Partial> = {}, +): Pick { + return { + listReleases: async () => [], + createDraftRelease: async (tag: string) => { + log.push(`github.createDraftRelease(${tag})`); + return { id: 99, uploadUrl: "https://uploads.example/99", htmlUrl: "https://example/releases/99" }; + }, + uploadAsset: async (_url: string, name: string) => { + log.push(`github.uploadAsset(${name})`); + }, + getReleaseAssetNames: async () => ["tecode-darwin-arm64", "tecode-darwin-arm64.sha256"], + ...overrides, + }; +} + +const OK_BUILD_OUTCOME: BuildOutcome = { target: LOCAL_TARGET, status: "ok", sizeBytes: 1234 }; + +function baseDeps(log: { push: (s: string) => void }, overrides: Partial = {}): TagReleaseDeps { + return { + platform: "darwin", + arch: "arm64", + env: { TECODE_RELEASE_TOKEN: "github_pat_x" }, + git: goodGit(log), + github: goodGithub(log), + buildTarget: async (target: ReleaseTarget) => { + log.push(`buildTarget(${target.bunTarget})`); + return OK_BUILD_OUTCOME; + }, + writeChecksumFile: async (target: ReleaseTarget) => { + log.push(`writeChecksumFile(${target.bunTarget})`); + return "/dist/tecode-darwin-arm64.sha256"; + }, + readFile: async (path: string) => { + log.push(`readFile(${path})`); + return new TextEncoder().encode("fake-bytes"); + }, + readReleaseNotes: async () => "notes", + log: () => {}, + logError: () => {}, + ...overrides, + }; +} + +describe("createTagReleaseRunner — happy path", () => { + test("runs every step in order: preflight, build, checksum, release, 2 uploads, completeness, tag, push", async () => { + const log = makeCallLog(); + const runner = createTagReleaseRunner(baseDeps(log)); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("done"); + if (outcome.stage === "done") { + expect(outcome.tag).toBe("v1.2.3"); + expect(outcome.releaseUrl).toBe("https://example/releases/99"); + } + + expect(log.calls).toEqual([ + "git.fetchOrigin", + "buildTarget(bun-darwin-arm64)", + "writeChecksumFile(bun-darwin-arm64)", + "github.createDraftRelease(v1.2.3)", + "readFile(/dist-root/dist/tecode-darwin-arm64)".replace("/dist-root", process.cwd()), + "github.uploadAsset(tecode-darwin-arm64)", + "readFile(/dist-root/dist/tecode-darwin-arm64.sha256)".replace("/dist-root", process.cwd()), + "github.uploadAsset(tecode-darwin-arm64.sha256)", + "git.createAnnotatedTag(v1.2.3)", + "git.pushTag(v1.2.3)", + ]); + }); +}); + +describe("createTagReleaseRunner — a failing preflight check mutates nothing", () => { + test("wrong host: build/release/tag are never called", async () => { + const log = makeCallLog(); + const runner = createTagReleaseRunner(baseDeps(log, { platform: "linux", arch: "x64" })); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("preflight-failed"); + expect(log.calls).toEqual(["git.fetchOrigin"]); // fetch is a harmless preflight read; nothing MUTATING ran + }); + + test("dirty working tree: build/release/tag are never called", async () => { + const log = makeCallLog(); + const runner = createTagReleaseRunner( + baseDeps(log, { git: { ...goodGit(log), statusPorcelain: async () => " M dirty.ts\n" } }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("preflight-failed"); + expect(log.calls).toEqual(["git.fetchOrigin"]); + }); + + test("malformed version: build/release/tag are never called, and no git/github tag-existence calls happen either", async () => { + const log = makeCallLog(); + const runner = createTagReleaseRunner(baseDeps(log)); + const outcome = await runner.run("not a version"); + + expect(outcome.stage).toBe("preflight-failed"); + expect(log.calls).toEqual(["git.fetchOrigin"]); + }); + + test("tag already exists on origin: build/release/tag are never called", async () => { + const log = makeCallLog(); + const runner = createTagReleaseRunner( + baseDeps(log, { git: { ...goodGit(log), tagExistsRemotely: async () => true } }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("preflight-failed"); + expect(log.calls).toEqual(["git.fetchOrigin"]); + }); + + test("a GitHub release already exists for this tag: build/release/tag are never called", async () => { + const log = makeCallLog(); + const existing: GitHubReleaseSummary[] = [{ id: 1, tag_name: "v1.2.3", draft: true }]; + const runner = createTagReleaseRunner( + baseDeps(log, { github: goodGithub(log, { listReleases: async () => existing }) }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("preflight-failed"); + expect(log.calls).toEqual(["git.fetchOrigin"]); + }); +}); + +describe("createTagReleaseRunner — a build failure happens before any release or tag", () => { + test("build-failed status: no release created, no checksum written, no tag pushed", async () => { + const log = makeCallLog(); + const failedOutcome: BuildOutcome = { + target: LOCAL_TARGET, + status: "build-failed", + reason: "simulated compile failure", + exitCode: 1, + }; + const runner = createTagReleaseRunner( + baseDeps(log, { + buildTarget: async (target: ReleaseTarget) => { + log.push(`buildTarget(${target.bunTarget})`); + return failedOutcome; + }, + }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("build-failed"); + if (outcome.stage === "build-failed") { + expect(outcome.buildOutcome.reason).toBe("simulated compile failure"); + } + // Build ran, but nothing after it did. + expect(log.calls).toEqual(["git.fetchOrigin", "buildTarget(bun-darwin-arm64)"]); + }); + + test("oversized build: no release created, no tag pushed", async () => { + const log = makeCallLog(); + const oversized: BuildOutcome = { target: LOCAL_TARGET, status: "oversized", sizeBytes: 999_999_999 }; + const runner = createTagReleaseRunner( + baseDeps(log, { buildTarget: async () => oversized }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("build-failed"); + expect(log.calls).not.toContain("github.createDraftRelease(v1.2.3)"); + expect(log.calls).not.toContain("git.pushTag(v1.2.3)"); + }); +}); + +describe("createTagReleaseRunner — step 3 succeeds, step 4 fails: recovery is reported, nothing extra mutated", () => { + test("tag creation fails after the draft release exists: no push happens, error surfaces the release for cleanup", async () => { + const log = makeCallLog(); + const errorMessages: string[] = []; + const runner = createTagReleaseRunner( + baseDeps(log, { + git: { ...goodGit(log), createAnnotatedTag: async () => { throw new Error("disk full"); } }, + logError: (m: string) => errorMessages.push(m), + }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("tag-create-failed"); + if (outcome.stage === "tag-create-failed") { + expect(outcome.releaseUrl).toBe("https://example/releases/99"); + expect(outcome.error).toContain("disk full"); + } + expect(log.calls).not.toContain("git.pushTag(v1.2.3)"); + expect(errorMessages.some((m) => m.includes("RECOVERY") && m.includes("https://example/releases/99"))).toBe(true); + }); + + test("tag push fails after the local tag was created: recovery names the exact push command and the release", async () => { + const log = makeCallLog(); + const errorMessages: string[] = []; + const runner = createTagReleaseRunner( + baseDeps(log, { + git: { ...goodGit(log), pushTag: async () => { throw new Error("remote rejected"); } }, + logError: (m: string) => errorMessages.push(m), + }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("tag-push-failed"); + if (outcome.stage === "tag-push-failed") { + expect(outcome.error).toContain("remote rejected"); + } + // The local tag WAS created (that part of step 4 succeeded). + expect(log.calls).toContain("git.createAnnotatedTag(v1.2.3)"); + expect( + errorMessages.some( + (m) => m.includes("RECOVERY") && m.includes("git push origin v1.2.3") && m.includes("https://example/releases/99"), + ), + ).toBe(true); + }); +}); + +describe("createTagReleaseRunner — upload / completeness failures never reach step 4", () => { + test("a mid-loop upload failure aborts before any tag is created or pushed", async () => { + const log = makeCallLog(); + const runner = createTagReleaseRunner( + baseDeps(log, { + github: goodGithub(log, { + uploadAsset: async (_url: string, name: string) => { + log.push(`github.uploadAsset(${name})`); + throw new Error("upload 500"); + }, + }), + }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("upload-failed"); + expect(log.calls).not.toContain("git.createAnnotatedTag(v1.2.3)"); + expect(log.calls).not.toContain("git.pushTag(v1.2.3)"); + }); + + test("an incomplete release at the completeness check aborts before any tag is created or pushed", async () => { + const log = makeCallLog(); + const runner = createTagReleaseRunner( + baseDeps(log, { + github: goodGithub(log, { getReleaseAssetNames: async () => ["tecode-darwin-arm64"] }), // .sha256 missing + }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("completeness-check-failed"); + if (outcome.stage === "completeness-check-failed") { + expect(outcome.missing).toEqual(["tecode-darwin-arm64.sha256"]); + } + expect(log.calls).not.toContain("git.createAnnotatedTag(v1.2.3)"); + expect(log.calls).not.toContain("git.pushTag(v1.2.3)"); + }); + + // The three failures in this area are genuinely different situations and + // want different recoveries, so they must not collapse onto one `stage`: + // an upload failed / the verifying read failed (uploads were fine, the + // draft may well be complete) / the read succeeded and said the draft is + // incomplete. This one used to report itself as "upload-failed", which + // told a caller the opposite of what happened. + test("a failure of the VERIFYING read is not reported as an upload failure", async () => { + const log = makeCallLog(); + const runner = createTagReleaseRunner( + baseDeps(log, { + github: goodGithub(log, { + getReleaseAssetNames: async () => { + throw new Error("502 from the assets endpoint"); + }, + }), + }), + ); + const outcome = await runner.run("v1.2.3"); + + expect(outcome.stage).toBe("completeness-verify-failed"); + // Every upload did happen and did succeed — the distinction the old + // "upload-failed" stage erased. + expect(log.calls).toContain("github.uploadAsset(tecode-darwin-arm64)"); + expect(log.calls).toContain("github.uploadAsset(tecode-darwin-arm64.sha256)"); + expect(log.calls).not.toContain("git.createAnnotatedTag(v1.2.3)"); + expect(log.calls).not.toContain("git.pushTag(v1.2.3)"); + }); +}); + +describe("LOCAL_TARGET", () => { + test("is the RELEASE_TARGETS entry with builtBy 'local' (bun-darwin-arm64)", () => { + expect(LOCAL_TARGET.bunTarget).toBe("bun-darwin-arm64"); + expect(LOCAL_TARGET.builtBy).toBe("local"); + }); +}); diff --git a/scripts/tagRelease.ts b/scripts/tagRelease.ts new file mode 100644 index 0000000..0451bf7 --- /dev/null +++ b/scripts/tagRelease.ts @@ -0,0 +1,938 @@ +/** + * `bun run tag ` (Req 13.2, design.md §17) — the local counterpart + * to `.circleci/config.yml`'s tag-triggered release pipeline, run by hand + * on the project owner's own Apple Silicon Mac. The owner declined to + * install a CircleCI machine runner on that Mac (`scripts/release.ts`'s + * `ReleaseTarget.builtBy` TSDoc explains the resulting split), so this + * script does everything a `build-darwin-arm64` CircleCI job otherwise + * would, PLUS creates the GitHub Release and pushes the tag that fires + * CircleCI for the other three targets — one command, in this exact order: + * + * 1. **Preflight** ({@link evaluatePreflight}) — every check runs, and ALL + * must pass, before any mutating step. Nothing here mutates anything; + * a failing check is reported with a specific, actionable message + * ({@link PreflightCheck.message}) naming what is wrong and what to do. + * 2. **Build** `bun-darwin-arm64` by calling {@link buildTarget} + * (`scripts/release.ts`) — reused, not duplicated: it already enforces + * `SIZE_LIMIT_BYTES`, and {@link writeChecksumFile} (also reused) + * writes the `.sha256` sibling. This is the step most likely to fail + * (a real compile, on real hardware, against whatever `bun`/dependency + * versions happen to be installed) — it runs FIRST, before anything + * external exists to clean up, so a failed build leaves nothing behind. + * 3. **Create a DRAFT GitHub Release** for the version and upload the two + * macOS assets (`tecode-darwin-arm64`, `tecode-darwin-arm64.sha256|`), + * then verify both landed ({@link checkAssetsComplete}) before moving + * on. + * 4. **Create and push the annotated git tag** — LAST, deliberately: it is + * the one irreversible step (pushing it fires CircleCI immediately; a + * tag push cannot be un-fired the way a draft release can simply be + * deleted), so everything reversible happens first. If step 3 already + * succeeded and this step fails, {@link createTagReleaseRunner}'s `run` + * prints exactly how to recover: the draft release to delete, or the + * exact `git tag`/`git push` command to run by hand to finish the job + * without repeating steps 1–3. + * + * Once the tag is pushed, `.circleci/config.yml`'s `release` workflow + * builds the three `builtBy: "circleci"` targets, finds the draft this + * script created (`.circleci/config.yml`'s `publish` job — see + * {@link findDraftReleaseByTag}, the algorithm it mirrors in bash), adds + * its own three targets' assets, verifies the release holds all + * `RELEASE_TARGETS.length` binaries and checksums, and publishes. + * + * ## Testability — everything that touches the network, `git`, the + * filesystem, or the clock is injected + * + * {@link GitPort} and {@link GitHubPort} are the two I/O seams (matching + * this codebase's `GitRunner`-style convention — see + * `packages/builtin/shared/gitRunner.ts`), each with a real, `Bun`-backed + * default ({@link createBunGitPort}, {@link createGitHubPort}) and a + * `createX(deps)` factory for the orchestrator itself + * ({@link createTagReleaseRunner}). Every PURE decision — preflight + * evaluation, version normalization, which release is the one to attach + * to, whether an upload is complete — lives in ordinary exported functions + * with their own direct `bun:test` coverage (`scripts/tagRelease.test.ts`, + * `scripts/githubRelease.test.ts`), never inside `run` itself. `run`'s own + * job is purely sequencing: gather inputs, evaluate, and — only once + * everything upstream says go — perform each mutating step in order, + * stopping and reporting recovery guidance the instant one fails. + */ + +import { resolve } from "node:path"; +import { + binaryFileName, + buildTarget, + formatBytesAsMB, + RELEASE_TARGETS, + writeChecksumFile, + type BuildOutcome, + type BuildTargetOptions, + type ChecksumOptions, + type ReleaseTarget, +} from "./release"; +import { checkAssetsComplete, findReleaseByTag, parseGitHubRemote, type GitHubReleaseSummary } from "./githubRelease"; + +// Re-exported purely so a caller of this module (and its own tests) can +// name the same selection algorithm `.circleci/config.yml`'s `publish` job +// mirrors in bash without a second import — see this module's own TSDoc, +// "Once the tag is pushed". +export { findDraftReleaseByTag } from "./githubRelease"; + +/** The one {@link ReleaseTarget} this script builds — resolved from + * {@link RELEASE_TARGETS} by {@link ReleaseTarget.builtBy} rather than + * hard-coded as `"bun-darwin-arm64"`, so the two files cannot silently + * disagree about which target is local. Thrown at import time (not + * awaited, not swallowed) if `RELEASE_TARGETS` is ever edited down to zero + * `"local"` entries — a configuration error this severe should fail + * immediately and loudly, not produce a confusing later failure. */ +export const LOCAL_TARGET: ReleaseTarget = (() => { + const target = RELEASE_TARGETS.find((t) => t.builtBy === "local"); + if (!target) { + throw new Error("scripts/tagRelease.ts: no RELEASE_TARGETS entry has builtBy 'local' — nothing for `bun run tag` to build"); + } + return target; +})(); + +/* ------------------------------------------------------------------ */ +/* Version normalization */ +/* ------------------------------------------------------------------ */ + +/** {@link normalizeVersionArg}'s result: either the normalized `v`-prefixed + * tag, or a human-readable reason the input was rejected. */ +export type VersionValidation = { readonly ok: true; readonly tag: string } | { readonly ok: false; readonly error: string }; + +/** + * Accepts `v1.2.3` or bare `1.2.3`, normalizes to a leading `v`; rejects + * anything else. A single leading `v` (lowercase, exactly one) is + * stripped, if present, before validating what remains against + * `[0-9A-Za-z.+-]+` — the task's own character class. Nothing more + * elaborate than that (no full semver grammar enforcement): the goal is to + * reject a version that would produce a malformed or unsafe git ref / + * GitHub `tag_name`, not to police version-numbering conventions. + * + * The character class alone is not sufficient for that goal. `git + * check-ref-format` rejects three shapes built entirely from characters it + * allows — a `..` anywhere, a trailing `.`, and a trailing `.lock` — so + * `v1..2`, `v1.2.` and `v1.2.lock` all pass a charset test and then fail + * at `git tag`. That failure would land in step 4, AFTER a real compile has + * run and a draft release full of uploaded assets exists, turning a typo + * into a wasted build plus a draft someone has to delete by hand. Rejecting + * them here costs nothing. + * + * Only those three are checked, because they are the only ones reachable: + * every other `check-ref-format` rule concerns characters (control codes, + * space, `~^:?*[\`, a trailing `/`) the class already excludes, or a + * component-leading `.`, which cannot occur since the component always + * begins with the normalized `v`. + */ +export function normalizeVersionArg(raw: string): VersionValidation { + const trimmed = raw?.trim() ?? ""; + if (trimmed.length === 0) { + return { ok: false, error: "no version given — usage: bun run tag , e.g. bun run tag v1.2.3" }; + } + const body = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed; + if (body.length === 0 || !/^[0-9A-Za-z.+-]+$/.test(body)) { + return { + ok: false, + error: `invalid version "${raw}" — expected something like v1.2.3 or 1.2.3 (after an optional leading "v", only 0-9, A-Z, a-z, ".", "+", "-" are allowed)`, + }; + } + // Shapes `git check-ref-format` rejects despite using allowed characters + // — see this function's TSDoc for why they are caught here and not left + // to fail at `git tag` in step 4. + const refUnsafe = body.includes("..") || body.endsWith(".") || body.endsWith(".lock"); + if (refUnsafe) { + return { + ok: false, + error: `invalid version "${raw}" — "v${body}" is not a valid git tag name (git rejects a ref containing "..", or ending in "." or ".lock")`, + }; + } + return { ok: true, tag: `v${body}` }; +} + +/* ------------------------------------------------------------------ */ +/* Preflight — pure evaluation over already-fetched inputs */ +/* ------------------------------------------------------------------ */ + +/** A value some I/O call was asked to produce, wrapped so a failed fetch + * becomes DATA (a specific check's failure message) rather than an + * exception that would stop every OTHER check from running too — the + * task's own "every check runs and must pass before any mutating step" + * requirement demands this: one flaky `git`/network call must not hide + * every other check's result. */ +export type Fetched = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }; + +/** One preflight check's outcome — {@link evaluatePreflight} always + * produces exactly the eight named in this module's TSDoc ("Preflight"), + * regardless of how many already failed, so a user sees everything wrong + * in one run rather than fixing issues one at a time across repeated + * invocations. */ +export interface PreflightCheck { + readonly name: string; + readonly ok: boolean; + /** Present iff `!ok` — names what is wrong and what to do about it + * (task requirement: "a specific, actionable message"). */ + readonly message?: string; +} + +/** Everything {@link evaluatePreflight} needs, already fetched by + * {@link gatherPreflightInputs} (or a test's own fixture) — kept as a + * flat, plain-data object specifically so `evaluatePreflight` itself stays + * a pure function with zero `await`s, directly testable against synthetic + * `Fetched` values without a single fake `git`/`fetch` call. */ +export interface PreflightInputs { + readonly platform: string; + readonly arch: string; + readonly env: Readonly>; + readonly rawVersion: string; + /** `git status --porcelain` output — non-empty means dirty. */ + readonly workingTreeStatus: Fetched; + readonly currentBranch: Fetched; + /** `git rev-parse main`. */ + readonly localMainSha: Fetched; + /** `git rev-parse origin/main`, taken AFTER a `git fetch origin main` — + * gathering this is what "fetch first" (task requirement) means in + * practice: without a fresh fetch, a stale `origin/main` ref would + * silently pass this check for a main that is actually behind. */ + readonly remoteMainSha: Fetched; + /** Only meaningful once the version itself is well-formed — see + * {@link gatherPreflightInputs} for why these default to + * `{ ok: true, value: false }` / `{ ok: true, value: [] }` otherwise. */ + readonly tagExistsLocally: Fetched; + readonly tagExistsRemotely: Fetched; + readonly existingReleases: Fetched; +} + +/** {@link evaluatePreflight}'s result. */ +export interface PreflightEvaluation { + readonly checks: readonly PreflightCheck[]; + readonly ok: boolean; + /** The normalized tag, present iff the version-format check passed — + * `run` uses this directly rather than re-normalizing. */ + readonly tag?: string; +} + +function check(name: string, ok: boolean, message?: string): PreflightCheck { + return ok ? { name, ok: true } : { name, ok: false, message }; +} + +/** Preflight check 1: this command builds the `bun-darwin-arm64` binary + * itself (Finding: `@opentui/core` cannot cross-compile — `scripts/ + * release.ts`'s TSDoc), so it must run on the exact platform/arch that + * binary is for. */ +export function checkHostIsMacSilicon(platform: string, arch: string): PreflightCheck { + return check( + "host is darwin/arm64", + platform === "darwin" && arch === "arm64", + `this command builds the bun-darwin-arm64 binary itself, so it must run on an Apple Silicon Mac (darwin/arm64) — detected ${platform}/${arch}. Run "bun run tag" on the target Mac; the other three targets are built by the CircleCI pipeline instead.`, + ); +} + +/** Preflight check 2: `TECODE_RELEASE_TOKEN` must be set — this script + * talks to the GitHub API directly (list releases, create a draft, upload + * assets), with no ambient credential the way CircleCI's own + * `TECODE_RELEASE_TOKEN` project variable is configured separately for its + * `publish` job. + * + * **Deliberately NOT named `GITHUB_TOKEN`**: that name is extremely common + * ambient tooling convention (the `gh` CLI, GitHub Actions runners, and + * assorted other tools all read it), so on the machine this script runs on + * it may already be set to a token with far broader scope than this one + * task needs — silently picking that up would run this script with + * whatever permissions that ambient token happens to carry, not the + * minimal one documented below. Naming this variable something this repo + * alone recognizes means a value is only ever present because someone set + * it FOR this script, never picked up by accident. + * + * **The minimum token this needs** (README's "Release" section has the + * full walkthrough): a fine-grained personal access token, "Repository + * access" limited to only `goofmint/tecode`, with exactly one repository + * permission granted — Contents: Read and write (GitHub files releases + * under Contents; this covers creating a draft release, uploading assets + * to it, and PATCHing it to published). A classic PAT is the wrong choice + * here: its `repo` scope grants access to every repository the owner can + * reach, plus issues, with no way to narrow it to just this one + * repository's release assets. This token is used ONLY for the GitHub + * REST API calls in this script ({@link GitHubPort}) — pushing the git tag + * itself (step 4) goes through the machine's ordinary `git` credentials, + * not this token, so it needs no push/write-to-git access at all. + * + * **No fallback to `GITHUB_TOKEN` or any other variable, ever** — if + * `TECODE_RELEASE_TOKEN` is unset, this check fails outright with a + * message naming the variable and the permission it needs. Falling back + * would silently reintroduce the exact ambient-scope problem this naming + * choice exists to avoid. + */ +export function checkReleaseTokenSet(env: Readonly>): PreflightCheck { + const token = env.TECODE_RELEASE_TOKEN; + return check( + "TECODE_RELEASE_TOKEN is set", + typeof token === "string" && token.trim().length > 0, + 'TECODE_RELEASE_TOKEN is not set in the environment — export a fine-grained GitHub personal access token, scoped to ONLY goofmint/tecode with the single repository permission "Contents: Read and write", before running `bun run tag` (e.g. `export TECODE_RELEASE_TOKEN=github_pat_...`). A classic PAT is the wrong choice here — see this check\'s own source comment for why.', + ); +} + +/** Preflight check 3: a dirty working tree must not be built and shipped + * as a release — the binary would not match any commit a later `git + * checkout ` could reproduce. */ +export function checkWorkingTreeClean(status: Fetched): PreflightCheck { + if (!status.ok) { + return check("git working tree is clean", false, `could not determine git status: ${status.error}`); + } + const dirty = status.value.trim().length > 0; + return check( + "git working tree is clean", + !dirty, + `working tree has uncommitted changes — commit, stash, or discard them first:\n${status.value.trim()}`, + ); +} + +/** Preflight check 4: releases are cut from `main`, never a feature + * branch — the tag pushed at the end of this flow must point at a commit + * that is (per check 5) actually on `origin/main`. */ +export function checkOnMainBranch(branch: Fetched): PreflightCheck { + if (!branch.ok) { + return check("current branch is main", false, `could not determine the current branch: ${branch.error}`); + } + return check( + "current branch is main", + branch.value === "main", + `current branch is "${branch.value}", not "main" — run \`git checkout main\` first.`, + ); +} + +/** Preflight check 5: local `main` must be identical to `origin/main` + * (task requirement's own "fetch first" — {@link PreflightInputs.remoteMainSha}'s + * TSDoc) — releasing from a local `main` that is ahead or behind origin + * would tag a commit nobody else's checkout of `main` actually has. */ +export function checkMainUpToDateWithOrigin(localSha: Fetched, remoteSha: Fetched): PreflightCheck { + if (!localSha.ok) { + return check("local main matches origin/main", false, `could not read local main's commit: ${localSha.error}`); + } + if (!remoteSha.ok) { + return check("local main matches origin/main", false, `could not fetch origin/main: ${remoteSha.error}`); + } + return check( + "local main matches origin/main", + localSha.value === remoteSha.value, + `local main (${localSha.value.slice(0, 12)}) differs from origin/main (${remoteSha.value.slice(0, 12)}) — run \`git pull --ff-only origin main\` (or push your local commits to origin) so the two match, then re-run.`, + ); +} + +/** Preflight check 7: the tag must not already exist, locally or on + * origin — pushing a tag `git push` refuses to move is a confusing way to + * discover a duplicate late; checking up front gives a clear reason + * instead. */ +export function checkTagNotTaken(tag: string, existsLocally: Fetched, existsRemotely: Fetched): PreflightCheck { + const name = `tag ${tag} does not already exist`; + if (!existsLocally.ok) { + return check(name, false, `could not check for a local tag: ${existsLocally.error}`); + } + if (!existsRemotely.ok) { + return check(name, false, `could not check for a remote tag: ${existsRemotely.error}`); + } + if (existsLocally.value) { + return check( + name, + false, + `tag ${tag} already exists locally — delete it first (\`git tag -d ${tag}\`) if this is a genuine retry, or pick a different version.`, + ); + } + return check( + name, + !existsRemotely.value, + `tag ${tag} already exists on origin — pick a different version, or delete the remote tag first if you are certain (\`git push origin :refs/tags/${tag}\`).`, + ); +} + +/** Preflight check 8: no GitHub release — draft or published — may + * already carry this `tag_name` ({@link findReleaseByTag}, `scripts/ + * githubRelease.ts`) — a leftover draft from a previous failed attempt, or + * an already-published release, must not be silently reused or + * duplicated. */ +export function checkNoExistingRelease(tag: string, releases: Fetched): PreflightCheck { + const name = `no GitHub release exists for ${tag}`; + if (!releases.ok) { + return check(name, false, `could not list GitHub releases: ${releases.error}`); + } + const existing = findReleaseByTag(releases.value, tag); + if (existing) { + return check( + name, + false, + `a GitHub release already exists for ${tag} (id ${existing.id}, ${existing.draft ? "draft" : "published"}) — delete it first if this is a genuine retry, or pick a different version.`, + ); + } + return check(name, true); +} + +/** + * Evaluate all eight preflight checks against already-gathered + * {@link PreflightInputs} — pure, synchronous, and the one function this + * module's tests exercise most directly (every combination of which + * check(s) fail, without a single real `git`/network call). ALWAYS + * produces all eight results, even when the version itself is malformed + * (checks 7–8 cannot meaningfully run without a valid tag, so they report + * themselves as skipped rather than being silently omitted from the list — + * the task's "every check runs" is about visibility, not about forcing a + * check to run against data that cannot exist). + */ +export function evaluatePreflight(inputs: PreflightInputs): PreflightEvaluation { + const version = normalizeVersionArg(inputs.rawVersion); + + const checks: PreflightCheck[] = [ + checkHostIsMacSilicon(inputs.platform, inputs.arch), + checkReleaseTokenSet(inputs.env), + checkWorkingTreeClean(inputs.workingTreeStatus), + checkOnMainBranch(inputs.currentBranch), + checkMainUpToDateWithOrigin(inputs.localMainSha, inputs.remoteMainSha), + check("version argument is well-formed", version.ok, version.ok ? undefined : version.error), + ]; + + if (version.ok) { + checks.push(checkTagNotTaken(version.tag, inputs.tagExistsLocally, inputs.tagExistsRemotely)); + checks.push(checkNoExistingRelease(version.tag, inputs.existingReleases)); + } else { + checks.push(check("tag does not already exist", false, "skipped — fix the version argument first")); + checks.push(check("no GitHub release exists for this version", false, "skipped — fix the version argument first")); + } + + return { checks, ok: checks.every((c) => c.ok), tag: version.ok ? version.tag : undefined }; +} + +/* ------------------------------------------------------------------ */ +/* GitPort — the injectable seam over the real `git` CLI */ +/* ------------------------------------------------------------------ */ + +/** Every `git` operation {@link gatherPreflightInputs}/`run` need, + * injectable so `scripts/tagRelease.test.ts` exercises the pure logic + * above (and `run`'s own sequencing) against hand-rolled fakes — no real + * repository, network, or `git` binary required (house `GitRunner`-style + * convention, `packages/builtin/shared/gitRunner.ts`). Unlike that + * `GitRunner`, methods here MAY reject: this is a standalone CLI tool, not + * a library boundary the running editor depends on to degrade gracefully, + * so a real failure propagating up to `run`'s own try/catch (which turns + * it into a specific, actionable message) is the right behavior. */ +export interface GitPort { + statusPorcelain(): Promise; + currentBranch(): Promise; + fetchOrigin(): Promise; + revParse(ref: string): Promise; + tagExistsLocally(tag: string): Promise; + tagExistsRemotely(tag: string): Promise; + remoteUrl(remote: string): Promise; + createAnnotatedTag(tag: string, message: string): Promise; + pushTag(tag: string): Promise; +} + +async function runGit(spawn: typeof Bun.spawn, args: readonly string[], cwd: string | undefined): Promise { + const proc = spawn({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed (exit ${exitCode}): ${(stderr || stdout).trim()}`); + } + return stdout; +} + +/** The real {@link GitPort}, over `Bun.spawn`. `cwd` defaults to + * `process.cwd()`'s ambient default (i.e. whatever `Bun.spawn` itself + * defaults to when `cwd` is `undefined`) — pass it explicitly only when + * running from outside the repo root. */ +export function createBunGitPort(deps: { spawn?: typeof Bun.spawn; cwd?: string } = {}): GitPort { + const spawn = deps.spawn ?? Bun.spawn; + const cwd = deps.cwd; + + return { + async statusPorcelain() { + return await runGit(spawn, ["status", "--porcelain"], cwd); + }, + async currentBranch() { + return (await runGit(spawn, ["rev-parse", "--abbrev-ref", "HEAD"], cwd)).trim(); + }, + async fetchOrigin() { + await runGit(spawn, ["fetch", "origin", "main"], cwd); + }, + async revParse(ref: string) { + return (await runGit(spawn, ["rev-parse", ref], cwd)).trim(); + }, + async tagExistsLocally(tag: string) { + return (await runGit(spawn, ["tag", "-l", tag], cwd)).trim() === tag; + }, + async tagExistsRemotely(tag: string) { + return (await runGit(spawn, ["ls-remote", "--tags", "origin", tag], cwd)).trim().length > 0; + }, + async remoteUrl(remote: string) { + return (await runGit(spawn, ["remote", "get-url", remote], cwd)).trim(); + }, + async createAnnotatedTag(tag: string, message: string) { + await runGit(spawn, ["tag", "-a", tag, "-m", message], cwd); + }, + async pushTag(tag: string) { + await runGit(spawn, ["push", "origin", tag], cwd); + }, + }; +} + +/* ------------------------------------------------------------------ */ +/* GitHubPort — the injectable seam over the GitHub REST API */ +/* ------------------------------------------------------------------ */ + +/** {@link GitHubPort.createDraftRelease}'s return shape. */ +export interface CreatedGitHubRelease { + readonly id: number; + /** Upload base URL with the `{?name,label}` URI-template suffix already + * stripped — ready to have `?name=` appended directly. */ + readonly uploadUrl: string; + readonly htmlUrl: string; +} + +/** Every GitHub Release API operation this script needs, injectable for + * the same reason {@link GitPort} is. */ +export interface GitHubPort { + /** `GET /releases?per_page=100` — deliberately NOT `GET /releases/tags/ + * {tag}`, which does not reliably return draft releases (this module's + * TSDoc, `scripts/githubRelease.ts`'s TSDoc). */ + listReleases(): Promise; + createDraftRelease(tag: string, name: string, body: string): Promise; + uploadAsset(uploadUrl: string, fileName: string, data: Uint8Array): Promise; + /** The release's current asset file names — used with + * {@link checkAssetsComplete} to confirm both macOS assets actually + * landed before moving on to the tag. */ + getReleaseAssetNames(releaseId: number): Promise; +} + +/** Dependencies for {@link createGitHubPort}. */ +export interface GitHubPortDeps { + token: string; + owner: string; + repo: string; + /** Overrides the fetch seam. Defaults to the global `fetch`. */ + fetchImpl?: typeof fetch; +} + +async function readErrorBody(res: Response): Promise { + try { + return (await res.text()).slice(0, 500); + } catch { + return ""; + } +} + +/** The real {@link GitHubPort}, over `fetch` and the GitHub REST API. */ +export function createGitHubPort(deps: GitHubPortDeps): GitHubPort { + const fetchImpl = deps.fetchImpl ?? fetch; + const base = `https://api.github.com/repos/${deps.owner}/${deps.repo}`; + const authHeaders: Record = { + Authorization: `Bearer ${deps.token}`, + Accept: "application/vnd.github+json", + }; + + async function listReleases(): Promise { + const res = await fetchImpl(`${base}/releases?per_page=100`, { headers: authHeaders }); + if (!res.ok) { + throw new Error(`GET /releases failed: ${res.status} ${await readErrorBody(res)}`); + } + const data = (await res.json()) as Array<{ id: number; tag_name: string; draft: boolean }>; + return data.map((r) => ({ id: r.id, tag_name: r.tag_name, draft: r.draft })); + } + + async function createDraftRelease(tag: string, name: string, body: string): Promise { + const res = await fetchImpl(`${base}/releases`, { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ tag_name: tag, name, body, draft: true }), + }); + if (!res.ok) { + throw new Error(`POST /releases failed: ${res.status} ${await readErrorBody(res)}`); + } + const data = (await res.json()) as { id: number; upload_url: string; html_url: string }; + return { id: data.id, uploadUrl: data.upload_url.split("{")[0]!, htmlUrl: data.html_url }; + } + + async function uploadAsset(uploadUrl: string, fileName: string, data: Uint8Array): Promise { + const res = await fetchImpl(`${uploadUrl}?name=${encodeURIComponent(fileName)}`, { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/octet-stream" }, + body: data, + }); + if (!res.ok) { + throw new Error(`asset upload for ${fileName} failed: ${res.status} ${await readErrorBody(res)}`); + } + } + + async function getReleaseAssetNames(releaseId: number): Promise { + const res = await fetchImpl(`${base}/releases/${releaseId}`, { headers: authHeaders }); + if (!res.ok) { + throw new Error(`GET /releases/${releaseId} failed: ${res.status} ${await readErrorBody(res)}`); + } + const data = (await res.json()) as { assets: Array<{ name: string }> }; + return data.assets.map((a) => a.name); + } + + return { listReleases, createDraftRelease, uploadAsset, getReleaseAssetNames }; +} + +/* ------------------------------------------------------------------ */ +/* Orchestration */ +/* ------------------------------------------------------------------ */ + +function describeError(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +async function safe(fn: () => Promise): Promise> { + try { + return { ok: true, value: await fn() }; + } catch (cause) { + return { ok: false, error: describeError(cause) }; + } +} + +/** + * Impure companion to {@link evaluatePreflight}: makes every `git`/GitHub + * call the eight checks need, wrapping each in {@link safe} so one failed + * call cannot prevent the other checks from being evaluated (task + * requirement: "every check runs"). Tag-dependent checks (7–8) are only + * actually queried when the version itself is well-formed — asking "does + * tag NaN exist" is meaningless, so those default to values that make + * {@link evaluatePreflight} report them as skipped instead of issuing a + * nonsensical query. + */ +export async function gatherPreflightInputs( + deps: { + platform: string; + arch: string; + env: Readonly>; + git: Pick; + github: Pick; + }, + rawVersion: string, +): Promise { + const [workingTreeStatus, currentBranch] = await Promise.all([ + safe(() => deps.git.statusPorcelain()), + safe(() => deps.git.currentBranch()), + ]); + + // "fetch first" (task requirement). A failed fetch must FAIL the + // origin/main comparison, not be discarded: `git rev-parse origin/main` + // happily resolves the stale local remote-tracking ref, and a stale ref + // is exactly the one most likely to equal local `main` — so swallowing + // the error turns check 5 into a check that passes precisely when it + // matters least, and lets a release be tagged at a commit behind the + // real origin/main. + const fetched = await safe(() => deps.git.fetchOrigin()); + + const [localMainSha, remoteMainShaRaw] = await Promise.all([ + safe(() => deps.git.revParse("main")), + safe(() => deps.git.revParse("origin/main")), + ]); + const remoteMainSha: Fetched = fetched.ok + ? remoteMainShaRaw + : { ok: false, error: `could not fetch origin (${fetched.error}) — origin/main may be stale, so it was not trusted` }; + + const version = normalizeVersionArg(rawVersion); + let tagExistsLocally: Fetched = { ok: true, value: false }; + let tagExistsRemotely: Fetched = { ok: true, value: false }; + let existingReleases: Fetched = { ok: true, value: [] }; + if (version.ok) { + [tagExistsLocally, tagExistsRemotely, existingReleases] = await Promise.all([ + safe(() => deps.git.tagExistsLocally(version.tag)), + safe(() => deps.git.tagExistsRemotely(version.tag)), + safe(() => deps.github.listReleases()), + ]); + } + + return { + platform: deps.platform, + arch: deps.arch, + env: deps.env, + rawVersion, + workingTreeStatus, + currentBranch, + localMainSha, + remoteMainSha, + tagExistsLocally, + tagExistsRemotely, + existingReleases, + }; +} + +/** Every outcome {@link createTagReleaseRunner}'s `run` can resolve to — + * one variant per stage this module's TSDoc numbers, plus the failure + * modes within steps 3–4. Exhaustive and tagged so a caller (or a test) + * can `switch` on `stage` without a default case. */ +export type TagReleaseOutcome = + | { readonly stage: "preflight-failed"; readonly preflight: PreflightEvaluation } + | { readonly stage: "build-failed"; readonly buildOutcome: BuildOutcome } + | { readonly stage: "checksum-failed"; readonly error: string } + | { readonly stage: "release-create-failed"; readonly error: string } + | { readonly stage: "upload-failed"; readonly releaseId: number; readonly releaseUrl: string; readonly error: string } + | { + readonly stage: "completeness-check-failed"; + readonly releaseId: number; + readonly releaseUrl: string; + readonly missing: readonly string[]; + } + /** The uploads all succeeded; the follow-up call that RE-READS the draft + * to confirm they landed is what failed. Distinct from `upload-failed` + * (an upload itself failed) and from `completeness-check-failed` (the + * re-read succeeded and reported the draft incomplete) because the + * recovery differs: here the draft may well be complete and simply + * unverified, so it is worth looking at before deleting it. */ + | { + readonly stage: "completeness-verify-failed"; + readonly releaseId: number; + readonly releaseUrl: string; + readonly error: string; + } + | { + readonly stage: "tag-create-failed"; + readonly releaseId: number; + readonly releaseUrl: string; + readonly tag: string; + readonly error: string; + } + | { + readonly stage: "tag-push-failed"; + readonly releaseId: number; + readonly releaseUrl: string; + readonly tag: string; + readonly error: string; + } + | { readonly stage: "done"; readonly tag: string; readonly releaseUrl: string }; + +/** Dependencies for {@link createTagReleaseRunner}. Every field that + * touches the network, `git`, the filesystem, or produces output is + * injectable and Pick<>-narrowed to exactly what `run` calls — the house + * convention this codebase's other `createX(deps)` factories follow + * (`packages/core/src/ui/hostErrorSink.ts`'s `HostErrorStatusSinkDeps`, + * among others). */ +export interface TagReleaseDeps { + /** Defaults to `process.platform`. */ + platform?: string; + /** Defaults to `process.arch`. */ + arch?: string; + /** Defaults to `process.env`. */ + env?: Readonly>; + git: Pick< + GitPort, + "statusPorcelain" | "currentBranch" | "fetchOrigin" | "revParse" | "tagExistsLocally" | "tagExistsRemotely" | "createAnnotatedTag" | "pushTag" + >; + github: Pick; + /** Reused from `scripts/release.ts` — never reimplemented here (task + * constraint: "reuse, don't duplicate"). Injectable so tests substitute + * a fake build instead of a real ~110 MB compile. */ + buildTarget: (target: ReleaseTarget, options?: BuildTargetOptions) => Promise; + /** Also reused from `scripts/release.ts`. */ + writeChecksumFile: (target: ReleaseTarget, options?: ChecksumOptions) => Promise; + /** Reads a built asset's raw bytes for upload. Defaults to + * `Bun.file(path).bytes()`. */ + readFile?: (path: string) => Promise; + /** Produces the GitHub Release body text. Defaults to reading + * `docs/release-notes-template.md` (the same file `.circleci/ + * config.yml`'s `publish` job used to read before this change — now + * read here instead, since this script is what creates the release). */ + readReleaseNotes?: () => Promise; + /** Directory {@link LOCAL_TARGET}'s binary/checksum are written to and + * read back from. Defaults to `"dist"`. */ + distDir?: string; + /** Working directory for both the build and the dist-relative reads. + * Defaults to `process.cwd()`. */ + repoRoot?: string; + /** Injectable line-oriented log sinks — default to `console.log`/ + * `console.error`. Tests inject their own to assert on output without + * printing during `bun test`. */ + log?: (message: string) => void; + logError?: (message: string) => void; +} + +/** {@link createTagReleaseRunner}'s return shape. */ +export interface TagReleaseRunner { + run(rawVersion: string): Promise; +} + +/** + * Build the `bun run tag ` orchestrator (this module's TSDoc for + * the full 4-step flow and why the order is fixed). `run` performs + * exactly one attempt: preflight (all 8 checks), then — only if every one + * passed — build, draft release + upload + completeness check, then + * create + push the tag, stopping at (and reporting recovery guidance + * for) the first mutating step that fails. + */ +export function createTagReleaseRunner(deps: TagReleaseDeps): TagReleaseRunner { + const platform = deps.platform ?? process.platform; + const arch = deps.arch ?? process.arch; + const env = deps.env ?? process.env; + const distDir = deps.distDir ?? "dist"; + const repoRoot = deps.repoRoot ?? process.cwd(); + const log = deps.log ?? ((message: string) => console.log(message)); + const logError = deps.logError ?? ((message: string) => console.error(message)); + const readFile = deps.readFile ?? (async (path: string) => await Bun.file(path).bytes()); + const readReleaseNotes = + deps.readReleaseNotes ?? (async () => await Bun.file(resolve(repoRoot, "docs/release-notes-template.md")).text()); + + async function run(rawVersion: string): Promise { + const inputs = await gatherPreflightInputs({ platform, arch, env, git: deps.git, github: deps.github }, rawVersion); + const preflight = evaluatePreflight(inputs); + for (const c of preflight.checks) { + log(c.ok ? `tag: [ok] ${c.name}` : `tag: [FAIL] ${c.name} — ${c.message}`); + } + if (!preflight.ok) { + logError("tag: preflight failed — nothing was built, released, or tagged. Fix the issue(s) above and re-run."); + return { stage: "preflight-failed", preflight }; + } + const tag = preflight.tag!; + log(`tag: preflight passed for ${tag}`); + + // Step 2: build. Most likely step to fail (a real compile on real + // hardware) — runs first, before anything external exists to clean up. + log(`tag: building ${LOCAL_TARGET.bunTarget}...`); + const buildOutcome = await deps.buildTarget(LOCAL_TARGET, { distDir, repoRoot }); + if (buildOutcome.status !== "ok") { + const detail = + buildOutcome.status === "oversized" + ? `over size budget (${formatBytesAsMB(buildOutcome.sizeBytes!)})` + : `build failed (exit ${buildOutcome.exitCode}): ${buildOutcome.reason}`; + logError(`tag: ${LOCAL_TARGET.bunTarget} — FAILED, ${detail}. Nothing was released or tagged.`); + return { stage: "build-failed", buildOutcome }; + } + log(`tag: ${LOCAL_TARGET.bunTarget} — OK, ${formatBytesAsMB(buildOutcome.sizeBytes!)}`); + + try { + await deps.writeChecksumFile(LOCAL_TARGET, { distDir, repoRoot }); + } catch (cause) { + const error = describeError(cause); + logError(`tag: failed to write the checksum — ${error}. Nothing was released or tagged.`); + return { stage: "checksum-failed", error }; + } + + const binaryName = binaryFileName(LOCAL_TARGET); + const checksumName = `${binaryName}.sha256`; + + // Step 3: draft release + upload + completeness check. + let created: CreatedGitHubRelease; + try { + const notes = await readReleaseNotes(); + created = await deps.github.createDraftRelease(tag, `tecode ${tag}`, notes); + } catch (cause) { + const error = describeError(cause); + logError( + `tag: failed to create the draft GitHub release — ${error}. Nothing was released or tagged; the built binary is still in ${distDir}/.`, + ); + return { stage: "release-create-failed", error }; + } + log(`tag: created draft release ${created.htmlUrl} (id ${created.id})`); + + try { + for (const name of [binaryName, checksumName]) { + const bytes = await readFile(resolve(repoRoot, distDir, name)); + log(`tag: uploading ${name}...`); + await deps.github.uploadAsset(created.uploadUrl, name, bytes); + } + } catch (cause) { + const error = describeError(cause); + logError(`tag: asset upload failed — ${error}.`); + logError( + `tag: RECOVERY — a draft release was created but may not have every asset: ${created.htmlUrl} (id ${created.id}). Delete it (GitHub UI, or DELETE /repos/.../releases/${created.id}) and re-run \`bun run tag ${tag}\` once fixed. No tag was created or pushed.`, + ); + return { stage: "upload-failed", releaseId: created.id, releaseUrl: created.htmlUrl, error }; + } + + try { + const assetNames = await deps.github.getReleaseAssetNames(created.id); + const completeness = checkAssetsComplete(assetNames, [binaryName, checksumName]); + if (!completeness.ok) { + logError(`tag: draft release is missing asset(s): ${completeness.missing.join(", ")}.`); + logError( + `tag: RECOVERY — delete the draft release ${created.htmlUrl} (id ${created.id}) and re-run \`bun run tag ${tag}\`. No tag was created or pushed.`, + ); + return { stage: "completeness-check-failed", releaseId: created.id, releaseUrl: created.htmlUrl, missing: completeness.missing }; + } + } catch (cause) { + const error = describeError(cause); + logError(`tag: could not verify the draft release's assets — ${error}.`); + logError( + `tag: RECOVERY — every upload reported success, so the draft may be complete but unverified: check ${created.htmlUrl} (id ${created.id}) by hand; delete it and re-run \`bun run tag ${tag}\` if it is incomplete. No tag was created or pushed.`, + ); + return { stage: "completeness-verify-failed", releaseId: created.id, releaseUrl: created.htmlUrl, error }; + } + log(`tag: draft release ${created.htmlUrl} has both macOS assets`); + + // Step 4: create + push the tag — LAST, because it is irreversible + // (pushing fires CircleCI immediately) while everything above is not + // (a draft release can simply be deleted). + try { + await deps.git.createAnnotatedTag(tag, `tecode ${tag}`); + } catch (cause) { + const error = describeError(cause); + logError(`tag: failed to create the local git tag ${tag} — ${error}.`); + logError( + `tag: RECOVERY — the draft release ${created.htmlUrl} (id ${created.id}) is ready and waiting. Either delete it, or once fixed create and push the tag by hand: git tag -a ${tag} -m "tecode ${tag}" && git push origin ${tag}`, + ); + return { stage: "tag-create-failed", releaseId: created.id, releaseUrl: created.htmlUrl, tag, error }; + } + + try { + await deps.git.pushTag(tag); + } catch (cause) { + const error = describeError(cause); + logError(`tag: created local tag ${tag} but failed to push it — ${error}.`); + logError( + `tag: RECOVERY — the draft release ${created.htmlUrl} (id ${created.id}) is ready. Push the tag by hand to fire CircleCI: git push origin ${tag}\n(Or, to abandon this attempt: git tag -d ${tag}, and delete the draft release.)`, + ); + return { stage: "tag-push-failed", releaseId: created.id, releaseUrl: created.htmlUrl, tag, error }; + } + + log(`tag: pushed ${tag} — CircleCI will build the remaining 3 targets and publish ${created.htmlUrl}.`); + return { stage: "done", tag, releaseUrl: created.htmlUrl }; + } + + return { run }; +} + +/* ------------------------------------------------------------------ */ +/* CLI entry point */ +/* ------------------------------------------------------------------ */ + +async function main(argv: string[]): Promise { + const rawVersion = argv[0]; + if (!rawVersion) { + console.error("usage: bun run tag (e.g. bun run tag v1.2.3)"); + process.exit(1); + return; + } + + const git = createBunGitPort(); + const remoteUrl = await git.remoteUrl("origin"); + const parsedRemote = parseGitHubRemote(remoteUrl); + if (!parsedRemote) { + console.error(`tag: could not parse a GitHub owner/repo out of origin's remote URL: "${remoteUrl}"`); + process.exit(1); + return; + } + + // No fallback to GITHUB_TOKEN or any other variable — see + // checkReleaseTokenSet's own TSDoc for why. An unset TECODE_RELEASE_TOKEN + // reaches the GitHub API as an empty bearer token, which simply fails + // authentication; checkReleaseTokenSet's own preflight check is what + // reports this specific, actionable ("export TECODE_RELEASE_TOKEN=...") + // message before anything mutating runs. + const github = createGitHubPort({ + token: process.env.TECODE_RELEASE_TOKEN ?? "", + owner: parsedRemote.owner, + repo: parsedRemote.repo, + }); + const runner = createTagReleaseRunner({ git, github, buildTarget, writeChecksumFile }); + const outcome = await runner.run(rawVersion); + process.exit(outcome.stage === "done" ? 0 : 1); +} + +if (import.meta.main) { + main(process.argv.slice(2)).catch((cause: unknown) => { + console.error("tag: failed:", cause); + process.exit(1); + }); +} diff --git a/tasks.md b/tasks.md index 946dd10..a6a30f6 100644 --- a/tasks.md +++ b/tasks.md @@ -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 4 binaries via the tag-triggered CircleCI pipeline (`.circleci/config.yml`). + - README (install, keybindings table, settings reference, terminal support matrix, fallback-keymap notes), sample `settings.json`/`keybindings.json`, versioned release publishing of the 4 binaries: `bun-darwin-arm64` built locally by `bun run tag ` (`scripts/tagRelease.ts`, run on the project owner's own Apple Silicon Mac), the other three via the tag-triggered CircleCI pipeline (`.circleci/config.yml`), which `publish`es by finding the draft release `bun run tag` already created rather than creating one itself. Pushing a `v*` tag by hand instead of running `bun run tag` produces a stuck pipeline, not a release. - _Req 9.5, 13.2, 13.3_ ---