From e7b72b97bc52d03c8f551470fdd76883421eb767 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 14:21:39 -0800 Subject: [PATCH 1/3] chore(quest): claim quest/m0/pin-bumps/release.md Quest-Claim: 85dd04763fd7c194 From 449ae095ca9fe2b89370885ce93535b2ee162f7f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 14:28:23 -0800 Subject: [PATCH 2/3] feat(release)!: release every merge that changes what ships Release now runs on each push to main, daily, and on demand, compares main with the latest published release, and cuts the next version when that range changes what ships. It builds and checks every binary before uploading them to a draft release, then publishes the draft, which creates the tag. package.json no longer carries a version: release binaries embed their tag, flake builds their commit, and source runs report `dev`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 3 +- .github/workflows/release.yml | 155 ++++++++++++++++++--------- .github/workflows/windows.yml | 2 +- CONTRIBUTING.md | 35 ++++-- SETUP.md | 2 + docs/windows.md | 2 +- flake.nix | 11 +- justfile | 5 +- nix/package.nix | 10 +- package.json | 4 +- quest/m0/pin-bumps/follow-release.md | 2 +- quest/m0/pin-bumps/release.md | 82 ++++---------- scripts/lifecycle-check.mjs | 5 +- scripts/lifecycle-check.sh | 5 +- scripts/release-plan.sh | 64 +++++++++++ scripts/release-plan.test.sh | 79 ++++++++++++++ src/cli/hooks.ts | 2 +- src/cli/main.ts | 3 +- src/cli/version.ts | 5 + test/cli.test.ts | 3 +- 20 files changed, 336 insertions(+), 143 deletions(-) create mode 100644 scripts/release-plan.sh create mode 100644 scripts/release-plan.test.sh create mode 100644 src/cli/version.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a444c682..589a0e77 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -66,7 +66,8 @@ jobs: run: nix build --out-link "$RUNNER_TEMP/quest-lifecycle-install" .#quest - name: Verify the installed CLI lifecycle shell: bash - run: nix develop --command bash scripts/lifecycle-check.sh "$RUNNER_TEMP/quest-lifecycle-install/bin/quest" + # A flake build reports its commit, as Nix abbreviates it. + run: nix develop --command bash scripts/lifecycle-check.sh "$RUNNER_TEMP/quest-lifecycle-install/bin/quest" "${GITHUB_SHA::7}" # Work outside the job holds a fresh macOS runner's load above its CPUs. - name: Check that the gate admits and owns its command's descendants shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d30b5f4..0d8abfd4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,33 +1,86 @@ name: Release -# A pushed version tag compiles, publishes, and then install-checks the release. -# Pull requests that could change the compiled binaries, and manual runs (even -# from a tag), only build and check them. Check runs the installer's own tests. +# Every run on `main` (each push, a daily schedule, or a manual run) compares +# `main` with the latest published release, and cuts the next one when that +# range changes what ships. It builds and checks every binary first, then +# uploads them to a draft release and publishes it, which creates the tag, so +# no tag exists without its complete release. A failed or cancelled run needs +# no recovery: the next run sees the same unreleased range and replaces any +# draft it left. Pull requests that change what ships, and manual runs on other +# branches, only build and check. scripts/release-plan.sh holds both decisions. on: push: - tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + branches: [main] pull_request: - paths: - - .github/workflows/release.yml - - package.json - - bun.lock - - bunfig.toml - - src/** - - scripts/** - - flake.lock + schedule: + - cron: "17 6 * * *" workflow_dispatch: permissions: contents: read +# Runs on `main` queue rather than cancel each other; a pending run that a newer +# one replaces loses nothing, since the newer one sees its range too. concurrency: group: release-${{ github.ref }} - cancel-in-progress: ${{ github.ref_type != 'tag' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + build: ${{ steps.plan.outputs.build }} + release: ${{ steps.plan.outputs.release }} + released: ${{ steps.plan.outputs.released }} + version: ${{ steps.plan.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Plan the release + id: plan + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + # The latest published release; drafts and prereleases don't count. + if ! released=$(gh api "repos/$GH_REPO/releases/latest" --jq .tag_name 2>"$RUNNER_TEMP/latest"); then + grep -q 'HTTP 404' "$RUNNER_TEMP/latest" || { cat "$RUNNER_TEMP/latest" >&2; exit 1; } + released= + fi + plan=$(bash scripts/release-plan.sh next "$released" "$GITHUB_SHA") + echo "$plan" + version=$(sed -n 's/^version=//p' <<<"$plan") + ships=$(sed -n 's/^release=//p' <<<"$plan") + release=false + if [[ $EVENT == pull_request ]]; then + build=false + if git diff --name-only "$BASE...$HEAD" | bash scripts/release-plan.sh ships; then build=true; fi + elif [[ $GITHUB_REF == refs/heads/main ]]; then + release=$ships + build=$ships + if [[ $EVENT == workflow_dispatch ]]; then build=true; fi + else + build=true + fi + { + echo "build=$build" + echo "release=$release" + echo "released=$released" + echo "version=$version" + } >>"$GITHUB_OUTPUT" + echo "build=$build release=$release version=$version released=${released:-none}" + # Bun cross-compiles every target from one Linux runner, since the pinned # nixpkgs no longer evaluates on Intel macOS. build: + needs: plan + if: needs.plan.outputs.build == 'true' runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -44,7 +97,10 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: package.json + # Each binary embeds the version it releases as, which `quest --version` prints. - name: Compile + env: + VERSION: ${{ needs.plan.outputs.version }} run: | declare -A bun_targets=( [aarch64-apple-darwin]=bun-darwin-arm64 @@ -54,12 +110,12 @@ jobs: ) mkdir release for target in "${!bun_targets[@]}"; do - bun run compile --target="${bun_targets[$target]}" + bun run compile --target="${bun_targets[$target]}" --define "QUEST_VERSION=\"$VERSION\"" tar -czf "release/quest-$target.tar.gz" -C dist quest done for pair in x86_64-pc-windows-msvc:bun-windows-x64 aarch64-pc-windows-msvc:bun-windows-arm64; do target=${pair%%:*} - bun run compile --target="${pair#*:}" + bun run compile --target="${pair#*:}" --define "QUEST_VERSION=\"$VERSION\"" zip -j "release/quest-$target.zip" dist/quest.exe done cd release @@ -72,7 +128,7 @@ jobs: check: name: check (${{ matrix.target }}) - needs: build + needs: [plan, build] strategy: fail-fast: false matrix: @@ -104,25 +160,11 @@ jobs: env: TARGET: ${{ matrix.target }} run: bash scripts/arch-check.sh "$RUNNER_TEMP/quest" "$TARGET" - - name: Check the version + - name: Check the binary's version and lifecycle shell: bash env: - REF_TYPE: ${{ github.ref_type }} - TAG: ${{ github.ref_name }} - run: | - actual=$("$RUNNER_TEMP/quest" --version) - expected="quest $(jq -r .version package.json)" - if [[ $actual != "$expected" ]]; then - echo "::error::the binary reports $actual, not $expected" - exit 1 - fi - if [[ $REF_TYPE == tag && $actual != "quest ${TAG#v}" ]]; then - echo "::error::$TAG builds $actual; bump package.json first" - exit 1 - fi - - name: Check the binary's lifecycle - shell: bash - run: bash scripts/lifecycle-check.sh "$RUNNER_TEMP/quest" + VERSION: ${{ needs.plan.outputs.version }} + run: bash scripts/lifecycle-check.sh "$RUNNER_TEMP/quest" "$VERSION" # The binary gates real commands. Work outside the job holds a fresh macOS # runner's load above its CPUs, which must not keep a lone command waiting. # Ubuntu 22.04's systemd 249 is too old for Quest's scopes, so it checks the @@ -132,12 +174,12 @@ jobs: run: bash scripts/gate-check.sh "$RUNNER_TEMP/quest" # Hosted macOS runners can queue past the timeout, which then reports a job - # that never ran as failed. Pull requests skip these; tags, which publish the - # artifacts, and manual runs still check them. Check covers macOS on main. + # that never ran as failed. Pull requests skip these; releases, which publish + # the artifacts, and manual runs still check them. Check covers macOS on main. macos: name: check (${{ matrix.target }}) if: github.event_name != 'pull_request' - needs: build + needs: [plan, build] strategy: fail-fast: false matrix: @@ -151,14 +193,15 @@ jobs: steps: *check-steps # Check's suite runs on Linux and Windows only, so the gate's macOS owner is - # tested natively before a tag publishes. + # tested natively before a release publishes. gate-macos: - if: github.event_name != 'pull_request' + needs: plan + if: github.event_name != 'pull_request' && needs.plan.outputs.build == 'true' uses: ./.github/workflows/gate-macos.yml windows: name: check (${{ matrix.target }}) - needs: build + needs: [plan, build] strategy: fail-fast: false matrix: @@ -185,15 +228,12 @@ jobs: shell: pwsh env: TARGET: ${{ matrix.target }} - REF_TYPE: ${{ github.ref_type }} - TAG: ${{ github.ref_name }} - run: | - $tag = if ($env:REF_TYPE -eq 'tag') { $env:TAG } else { 'v' + (Get-Content package.json | ConvertFrom-Json).version } - ./scripts/install-check.ps1 -Method archive -Target $env:TARGET -Tag $tag -ReleaseDirectory (Resolve-Path release).Path + TAG: v${{ needs.plan.outputs.version }} + run: ./scripts/install-check.ps1 -Method archive -Target $env:TARGET -Tag $env:TAG -ReleaseDirectory (Resolve-Path release).Path publish: - needs: [check, macos, gate-macos, windows] - if: github.event_name == 'push' && github.ref_type == 'tag' + needs: [plan, check, macos, gate-macos, windows] + if: needs.plan.outputs.release == 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -210,18 +250,29 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} + TAG: v${{ needs.plan.outputs.version }} + RELEASED: ${{ needs.plan.outputs.released }} run: | + # A run that failed after uploading leaves a draft, which this replaces. + gh api --paginate "repos/$GH_REPO/releases" --jq '.[] | select(.draft) | "\(.id) \(.tag_name)"' | + while read -r id tag; do + if [[ $tag == v[0-9]* ]]; then gh api -X DELETE "repos/$GH_REPO/releases/$id"; fi + done sed "s/@TAG@/$TAG/" scripts/installer.sh > release/quest-installer.sh cd release sha256sum quest-*.tar.gz quest-*.zip quest-installer.sh > SHA256SUMS - prerelease=() - if [[ $TAG == *-* ]]; then prerelease=(--prerelease); fi - gh release create "$TAG" --verify-tag --title "$TAG" --generate-notes "${prerelease[@]}" \ + notes=(--generate-notes) + if [[ -n $RELEASED ]]; then notes+=(--notes-start-tag "$RELEASED"); fi + gh release create "$TAG" --draft --target "$GITHUB_SHA" --title "$TAG" "${notes[@]}" \ quest-*.tar.gz quest-*.zip quest-installer.sh SHA256SUMS + # Publishing the draft creates its tag at this run's commit, with every asset already attached. + id=$(gh api --paginate "repos/$GH_REPO/releases" --jq ".[] | select(.draft and .tag_name == \"$TAG\") | .id") + gh api -X PATCH "repos/$GH_REPO/releases/$id" -F draft=false -f make_latest=true --jq .html_url + # Mise and the installer need the published release, so they check it last and + # fail loudly; their fix ships as the next release. install-check: - needs: publish + needs: [plan, publish] uses: ./.github/workflows/install-check.yml with: - tag: ${{ github.ref_name }} + tag: v${{ needs.plan.outputs.version }} diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 63d5e5d3..bb041965 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -41,4 +41,4 @@ jobs: run: bun run test - run: bun run build - name: Verify native setup and removal without symlinks - run: bun scripts/lifecycle-check.mjs dist/quest.js + run: bun scripts/lifecycle-check.mjs dist/quest.js dev diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed73a8d4..410ad936 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,14 +51,29 @@ The package stays `private`; GitHub releases will ship the `quest` binary. ## Releases -Pushing a `vX.Y.Z` tag that matches `package.json`'s version runs the Release -workflow. It compiles `quest` with Bun for macOS, Linux, and Windows (arm64 and -x64), runs the lifecycle check on each binary's native runner, and publishes the -macOS/Linux tarballs, Windows ZIPs, `SHA256SUMS`, and `quest-installer.sh` to a -GitHub release. It then checks that mise and the installer (or, on Windows, the -ZIP archive) install the tag on every platform. A tag with a -prerelease suffix publishes a prerelease. `just compile` builds a binary for -this machine; the Bun in the Nix shell is the version releases use. +The Release workflow cuts every release; no person or agent tags one. Each +push to `main`, a daily schedule, and a manual run on `main` compare `main` +with the latest published release, and cut the next one when that range +changes what ships, as `scripts/release-plan.sh` decides: everything the +binaries and the flake package build from, every file Release publishes, and +the workflows that build and check them. Its pull request runs use the same +predicate to decide whether to build. The next version is greater than every +`vX.Y.Z` tag: a minor bump (a major one from 1.0) when a commit subject in the +range has `!` before its colon, a patch bump otherwise. -Do not push release tags or publish GitHub releases from ordinary development -work; that is a maintainer action after review. +A run compiles `quest` with Bun for macOS, Linux, and Windows (arm64 and x64), +each binary embedding its version, and runs the lifecycle and archive install +checks on each binary's native runner. Only then does it upload the +macOS/Linux tarballs, Windows ZIPs, `SHA256SUMS`, and `quest-installer.sh` to +a draft release and publish it, which creates the tag. A run that fails or is +cancelled needs no recovery: the next one sees the same unreleased range and +replaces the draft. After publishing, it checks that mise and the installer +install the release on every platform; a failure there ships its fix as the +next release. + +`package.json` carries no version. A release binary reports its tag in +`quest --version`, a flake build its commit, and a checkout run from source +`dev`. `just compile` builds a binary for this machine; the Bun in the Nix +shell is the version releases use. + +Do not push release tags or publish GitHub releases by hand. diff --git a/SETUP.md b/SETUP.md index 2c459ffb..209d3807 100644 --- a/SETUP.md +++ b/SETUP.md @@ -12,6 +12,8 @@ line in its root instructions. ## Install and pin Pin the latest [release](https://github.com/moq-dev/quest/releases) tag. +Every merge that changes what Quest ships becomes a release, so a release tag +is never far behind `main`; prefer it to pinning a commit. Releases ship binaries for macOS, Linux, and native Windows on arm64 and x64. Check that the chosen release includes the Windows archives and the `quest worktree hook` command that `quest init`'s Claude Code hooks call; diff --git a/docs/windows.md b/docs/windows.md index dc3dca28..9262cb88 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -113,7 +113,7 @@ explicitly choose to discard it. [Bun](https://bun.sh) is required for a source build. From the checkout, run `bun install --frozen-lockfile`, `bun run check`, `bun run test`, and -`bun run build`. Then run `bun scripts/lifecycle-check.mjs dist/quest.js`. The shared lifecycle checks +`bun run build`. Then run `bun scripts/lifecycle-check.mjs dist/quest.js dev`. The shared lifecycle checks paths containing spaces, actual Git worktrees, CRLF plans/instructions, existing custom skills, conflicts, and repeated setup/removal without remote writes. Windows tests do not require symlink privileges; symlink-specific legacy cases diff --git a/flake.nix b/flake.nix index 33bfa3d5..677b0d90 100644 --- a/flake.nix +++ b/flake.nix @@ -42,8 +42,15 @@ inherit system; overlays = [ (_: _: { inherit (nixpkgs-bun.legacyPackages.${system}) bun; }) ]; }; - quest = pkgs.callPackage ./nix/package.nix { }; - questPinned = pkgs.callPackage "${quest-pinned}/nix/package.nix" { }; + # A flake build cannot see tags, so it reports its commit. + quest = pkgs.callPackage ./nix/package.nix { rev = self.shortRev or self.dirtyShortRev or null; }; + # A pin from before builds took `rev` reads its version from package.json instead. + pinnedPackage = "${quest-pinned}/nix/package.nix"; + questPinned = pkgs.callPackage pinnedPackage ( + pkgs.lib.optionalAttrs (pkgs.lib.functionArgs (import pinnedPackage) ? rev) { + rev = quest-pinned.shortRev; + } + ); manifest = pkgs.lib.importJSON ./package.json; # package.json's packageManager pins Bun for CI runners without Nix. bunVersion = pkgs.lib.removePrefix "bun@" manifest.packageManager; diff --git a/justfile b/justfile index b58a2b21..7512a2de 100644 --- a/justfile +++ b/justfile @@ -32,10 +32,11 @@ _test: bun run test bash scripts/install-check.test.sh bash scripts/installer.test.sh + bash scripts/release-plan.test.sh bun run build - bash scripts/lifecycle-check.sh "$PWD/dist/quest.js" + bash scripts/lifecycle-check.sh "$PWD/dist/quest.js" dev bun run compile - bash scripts/lifecycle-check.sh "$PWD/dist/quest" + bash scripts/lifecycle-check.sh "$PWD/dist/quest" dev # Check that `quest review` runs nothing a reviewed head plants, with the real # Codex and Claude Code CLIs. Uses each CLI's login and spends its usage; run it diff --git a/nix/package.nix b/nix/package.nix index c79e138d..221a09fa 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,11 +1,14 @@ -# The `quest` CLI built from source: the same Bun bundle `bun run build` makes. +# The `quest` CLI built from source: the same Bun bundle `bun run build` makes, +# reporting `rev`, the commit the flake passes, as its version. { lib, stdenvNoCC, bun, fetchurl, + rev ? null, }: let + version = if rev == null then "dev" else rev; manifest = lib.importJSON ../package.json; # bun.lock is JSON with trailing commas. lock = builtins.fromJSON ( @@ -79,7 +82,7 @@ let in stdenvNoCC.mkDerivation { pname = manifest.name; - inherit (manifest) version; + inherit version; src = lib.fileset.toSource { root = ../.; @@ -88,7 +91,6 @@ stdenvNoCC.mkDerivation { ../bunfig.toml ../tsconfig.json ../src - ../scripts/executable.mjs ../assets ]; }; @@ -104,7 +106,7 @@ stdenvNoCC.mkDerivation { buildPhase = '' runHook preBuild export HOME=$TMPDIR - bun run build + bun run bundle --define ${lib.escapeShellArg "QUEST_VERSION=${builtins.toJSON version}"} runHook postBuild ''; diff --git a/package.json b/package.json index 73bed944..6e16fc2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "name": "quest", - "version": "0.2.1", "description": "Versioned Markdown quests: validate plans, find ready work, and name the branches a quest lands on.", "license": "MIT OR Apache-2.0", "author": "Luke Curley", @@ -15,7 +14,8 @@ }, "packageManager": "bun@1.4.4", "scripts": { - "build": "bun build src/cli/bin.ts --target=bun --banner \"#!/usr/bin/env -S bun --no-env-file\" --outfile dist/quest.js && bun scripts/executable.mjs dist/quest.js", + "build": "bun run bundle && bun scripts/executable.mjs dist/quest.js", + "bundle": "bun build src/cli/bin.ts --target=bun --banner \"#!/usr/bin/env -S bun --no-env-file\" --outfile dist/quest.js", "compile": "bun build src/cli/bin.ts --compile --no-compile-autoload-dotenv --outfile dist/quest", "quest": "bun src/cli/bin.ts", "check": "tsc && tsc -p src/core && prettier --check .", diff --git a/quest/m0/pin-bumps/follow-release.md b/quest/m0/pin-bumps/follow-release.md index 7580f1ba..cf010b33 100644 --- a/quest/m0/pin-bumps/follow-release.md +++ b/quest/m0/pin-bumps/follow-release.md @@ -27,4 +27,4 @@ until `main` moves again. Update README.md's "Pin bumps" section to match. ## Required -- [Release every merge that changes what ships](/quest/m0/pin-bumps/release.md) - the releases a bump follows +- [Check the first automated release](/quest/m0/pin-bumps/release.md) - a published release for a bump to follow diff --git a/quest/m0/pin-bumps/release.md b/quest/m0/pin-bumps/release.md index b36b2a10..61470e70 100644 --- a/quest/m0/pin-bumps/release.md +++ b/quest/m0/pin-bumps/release.md @@ -1,67 +1,31 @@ -# [M] Release every merge that changes what ships +# [XS] Check the first automated release ## Goal -Each push to `main` that changes what ships publishes a new quest release -with no person involved: the Release workflow picks the next version, tags -it, and publishes the binaries, so the biweekly cut and the maintainer's tag -push are gone. `quest --version` names the release in its binaries, and a -flake build names its release or commit. +The first release the Release workflow cuts on its own is complete and +installable, and the docs stop treating v0.1.0 as the only release. ## Plan -Decided 2026-11-12 (maintainer, /quest-plan; see the -[epic](/quest/m0/pin-bumps/README.md)): ✅ the workflow cuts every release -(rejected: each user-facing PR bumps `package.json` and a merge-time workflow -tags it, since PRs racing through the merge queue would conflict on that -line). This replaces the biweekly cut decided 2026-11-09 on -[moq-dev/quest#587](https://github.com/moq-dev/quest/pull/587) and lifts -CONTRIBUTING.md's rule reserving release tags for the maintainer: no release -has shipped since v0.1.0, because v0.2.0's run failed and v0.2.1 was never -tagged. +[moq-dev/quest#708](https://github.com/moq-dev/quest/pull/708) made the +Release workflow cut a release for every merge to `main` that changes what +ships (see CONTRIBUTING.md's Releases section), as decided 2026-11-12 on the +[epic](/quest/m0/pin-bumps/README.md). Its first release exists only after that +PR merges. Once it does, check it: -Decided 2026-11-12 (maintainer, at the review's design checkpoint): ✅ Release -reconciles `main` with the published releases, and tagging is its last step -(rejected: recovery steps for each way a run can fail, which review kept -finding new cases of; semantic-release, an added dependency that publishes -before the checks this needs to pass first). So: +- The release lists both Windows ZIPs that SETUP.md expects, beside the + macOS/Linux tarballs, `SHA256SUMS`, and `quest-installer.sh`. +- Every job of the run's Install check passed. A failure ships its fix as the + next release; fix it here and check that release instead. +- The release binary has `quest worktree hook`, the Goal of + [the v0.2.1 cut](https://github.com/moq-dev/quest/blob/2a80c902/quest/m1/release.md): + `quest worktree hook --help` exits 0. -- Every run, on each push to `main` and on a daily schedule, compares `main` - with the latest published release and cuts one when that range touches - what ships: everything the binaries or the flake package build from, - `assets/` (bundled into the binary) and `nix/` included, plus every file - Release publishes, such as `scripts/installer.sh`. Release's pull request - path filter, which misses `assets/` and `nix/` today, uses the same - predicate, so the two cannot drift. A - cancelled or failed run then needs no recovery: the next run sees the same - unreleased range. Runs on `main` queue rather than cancel each other. -- A run builds every binary and passes the lifecycle and archive install - checks on those local artifacts before it tags. It then uploads every - asset to a draft release and publishes the draft, which creates the tag, so - no tag exists without its complete release; a run that fails before that - leaves only a draft, which the next run replaces. The next version is - greater than every existing tag, so the dead v0.2.0 tag is skipped (v0.2.1 - was never tagged): minor when the range holds a `!` subject, patch - otherwise. -- The checks that need a published release, mise and the installer's URL, - run after it and fail loudly; their fix ships as the next release. They - block no consumer, since the bump pass moves flake pins, which build from - source at the release's commit. -- The version comes from the tag: `package.json` stops carrying one, so no - release needs a PR. Everything that reads it moves to the build's input: - find them by searching for it, since they span the CLI (`src/cli/main.ts`, - `src/cli/hooks.ts`), `nix/package.nix`, `scripts/lifecycle-check.mjs`, the - tests, and the workflows' version checks and Windows fallback tag. A - release binary embeds its tag; a flake build, which cannot see tags, embeds - its commit, which `flake.nix` passes to `nix/package.nix`. It also builds - `quest-pinned` from that input's own, older `nix/package.nix`, so the new - argument must not break that call. -- Update CONTRIBUTING.md's Releases section and SETUP.md's pinning advice. -- The first automated release exists only after the implementing PR merges. - That PR rewrites this quest to its checks, with a `## Waiting` item on the - release, rather than deleting it: the Windows ZIPs SETUP.md expects, every - Install check job, and that a release binary has `quest worktree hook`, the - Goal of [the v0.2.1 cut](https://github.com/moq-dev/quest/blob/2a80c902/quest/m1/release.md). Then drop SETUP.md's v0.1.0 caveat and the "next release" - notes in README.md and docs/windows.md, and delete this quest. -- Milestone: ✅ m0 (rejected: m1), so those first-release checks, which the - deleted m1 quests held, move to m0 with it. +Then drop SETUP.md's v0.1.0 caveat and the "next release" notes in README.md +and docs/windows.md, and delete this quest. + +## Waiting + +- moq-dev/quest: the Release workflow publishes its first automated release + after #708 merges; check that `gh release list --repo moq-dev/quest` shows a + release newer than v0.1.0, or run the Release workflow on `main` by hand. diff --git a/scripts/lifecycle-check.mjs b/scripts/lifecycle-check.mjs index 02d9b5b6..7f3131d9 100644 --- a/scripts/lifecycle-check.mjs +++ b/scripts/lifecycle-check.mjs @@ -15,8 +15,9 @@ import { tmpdir } from "node:os"; import { dirname, extname, join, resolve } from "node:path"; const input = resolve(process.argv[2]); -const expectedVersion = - process.argv[3] ?? JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; +// The version the build embeds: a release's tag without its `v`, a flake build's commit, or `dev`. +const expectedVersion = process.argv[3]; +if (!expectedVersion) throw new Error("usage: lifecycle-check.mjs "); const work = mkdtempSync(join(tmpdir(), "quest lifecycle ")); const write = (root, path, text) => { mkdirSync(dirname(join(root, path)), { recursive: true }); diff --git a/scripts/lifecycle-check.sh b/scripts/lifecycle-check.sh index bd362ff1..095376f7 100644 --- a/scripts/lifecycle-check.sh +++ b/scripts/lifecycle-check.sh @@ -2,7 +2,8 @@ # Exercise an installed CLI in fresh repositories without remote writes. set -euo pipefail -binary=${1:?usage: lifecycle-check.sh /absolute/path/to/quest} +binary=${1:?usage: lifecycle-check.sh /absolute/path/to/quest expected-version} +version=${2:?usage: lifecycle-check.sh /absolute/path/to/quest expected-version} case "$binary" in /*) ;; *) @@ -18,7 +19,7 @@ trap 'rm -rf "$work"' EXIT # binary in place, so give it a path with spaces. mkdir "$work/tool bin" cp "$binary" "$work/tool bin/" -bun "$(dirname -- "${BASH_SOURCE[0]}")/lifecycle-check.mjs" "$work/tool bin/$(basename -- "$binary")" +bun "$(dirname -- "${BASH_SOURCE[0]}")/lifecycle-check.mjs" "$work/tool bin/$(basename -- "$binary")" "$version" # A reader that closes stdout early, like `quest unblocked | head -1`, ends the # command quietly. The writer waits on a FIFO until the reader has closed, so diff --git a/scripts/release-plan.sh b/scripts/release-plan.sh new file mode 100644 index 00000000..195f6c9c --- /dev/null +++ b/scripts/release-plan.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Plan a Release run from git alone, so each of its decisions is testable offline. +# +# release-plan.sh ships +# Read paths on stdin; exit 0 when any changes what a release ships. +# release-plan.sh next +# Print `version=X.Y.Z`, the release after every vX.Y.Z tag, and +# `release=true` or `release=false`: whether the commits in +# .. change what a release ships. An empty +# means nothing is released yet, so every commit up to counts. +set -euo pipefail + +usage() { + echo 'usage: release-plan.sh ships | release-plan.sh next ' >&2 + exit 2 +} + +# What a release ships or is built and checked by: everything the binaries and +# the flake package build from (`assets/` is bundled into the binary), every +# file Release publishes, such as `scripts/installer.sh`, and the workflows that +# build, check, and publish it. Release's pull request runs and its releases on +# `main` both decide with this one predicate. +ships() { + grep -E '^(src/|assets/|nix/|scripts/|package\.json$|bun\.lock$|bunfig\.toml$|tsconfig\.json$|flake\.(nix|lock)$|\.github/workflows/(release|gate-macos|install-check)\.yml$)' >/dev/null +} + +next() { + local released=$1 head=$2 range latest major minor patch + range=$head + if [[ -n $released ]]; then range="$released..$head"; fi + # Greater than every tag, even one whose release never published. + latest=$(git tag -l 'v*' | { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' || true; } | sort -V | tail -n 1) + IFS=. read -r major minor patch <<<"${latest#v}" + major=${major:-0} minor=${minor:-0} patch=${patch:-0} + # A conventional commit subject with `!` before its colon breaks compatibility. + if git log --format=%s "$range" | grep -E '^[A-Za-z]+(\([^)]*\))?!:' >/dev/null; then + if ((major == 0)); then + minor=$((minor + 1)) + else + major=$((major + 1)) minor=0 + fi + patch=0 + else + patch=$((patch + 1)) + fi + echo "version=$major.$minor.$patch" + if git log --format= --name-only "$range" | ships; then + echo release=true + else + echo release=false + fi +} + +case "${1:-}" in + ships) + (($# == 1)) || usage + ships + ;; + next) + (($# == 3)) || usage + next "$2" "$3" + ;; + *) usage ;; +esac diff --git a/scripts/release-plan.test.sh b/scripts/release-plan.test.sh new file mode 100644 index 00000000..ab5884b4 --- /dev/null +++ b/scripts/release-plan.test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Offline regressions for Release's plan: what ships, and the next version. +set -euo pipefail + +plan="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/release-plan.sh" +work=$(mktemp -d "${TMPDIR:-/tmp}/quest-release-plan-test.XXXXXX") +trap 'rm -rf "$work"' EXIT + +fail() { + printf 'release-plan: %s\n' "$*" >&2 + exit 1 +} +expect() { + local want=$1 got + shift + got=$(bash "$plan" "$@") + [[ $got == "$want" ]] || fail "$* printed '${got//$'\n'/ }', not '${want//$'\n'/ }'" +} + +for path in src/cli/main.ts assets/skills/x/SKILL.md nix/package.nix scripts/installer.sh package.json \ + bun.lock bunfig.toml tsconfig.json flake.nix flake.lock .github/workflows/release.yml \ + .github/workflows/install-check.yml .github/workflows/gate-macos.yml; do + bash "$plan" ships <<<"$path" || fail "$path should ship" +done +for path in README.md quest/m0/x.md test/cli.test.ts docs/windows.md .github/workflows/check.yml \ + package.json.bak srcs/x.ts; do + if bash "$plan" ships <<<"$path"; then fail "$path should not ship"; fi +done +printf 'README.md\nsrc/cli/main.ts\n' | bash "$plan" ships || fail 'a list with one shipping path should ship' + +git init --quiet "$work/repo" +cd "$work/repo" +git config user.name test +git config user.email test@example.com +commit() { + mkdir -p "$(dirname -- "$1")" + echo "$RANDOM" >>"$1" + git add "$1" + git commit --quiet -m "$2" +} + +# Nothing released or tagged yet. +commit README.md 'docs: start' +expect $'version=0.0.1\nrelease=false' next '' HEAD +commit src/a.ts 'feat: ship' +expect $'version=0.0.1\nrelease=true' next '' HEAD + +git tag v0.1.0 +expect $'version=0.1.1\nrelease=false' next v0.1.0 HEAD +commit README.md 'feat(docs)!: only docs' +expect $'version=0.2.0\nrelease=false' next v0.1.0 HEAD +commit assets/x.md 'fix(skills): ship a skill' +expect $'version=0.2.0\nrelease=true' next v0.1.0 HEAD + +# A tag whose release never published is skipped, as are prerelease and odd tags. +git tag v0.2.0 +git tag v0.9.0-rc.1 +git tag vnext +expect $'version=0.3.0\nrelease=true' next v0.1.0 HEAD +commit src/b.ts 'fix: patch' +expect $'version=0.2.1\nrelease=true' next v0.2.0 HEAD +commit src/b.ts 'refactor!: break' +expect $'version=0.3.0\nrelease=true' next v0.1.0 HEAD + +# A release at or past the head leaves nothing to release. +git tag v0.3.0 +expect $'version=0.3.1\nrelease=false' next v0.3.0 HEAD +expect $'version=0.3.1\nrelease=false' next v0.3.0 HEAD~1 + +# A subject merely mentioning `!` is not breaking; after 1.0, `!` bumps the major. +git tag v1.4.2 +commit src/c.ts 'fix: handle "a!: b"' +expect $'version=1.4.3\nrelease=true' next v1.4.2 HEAD +commit nix/package.nix 'build(nix)!: change' +expect $'version=2.0.0\nrelease=true' next v1.4.2 HEAD + +if bash "$plan" next v1.4.2 2>/dev/null; then fail 'a missing head should be a usage error'; fi + +echo 'release plan: shipping paths and next versions passed' diff --git a/src/cli/hooks.ts b/src/cli/hooks.ts index 06eeedc2..952f2ce2 100644 --- a/src/cli/hooks.ts +++ b/src/cli/hooks.ts @@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { version } from "../../package.json"; +import { version } from "./version"; import { type StopInput, broken, questRoot, runningGates, usesQuest } from "./rules"; import { stubFindings } from "./setup"; import { CONTEXT } from "./skills"; diff --git a/src/cli/main.ts b/src/cli/main.ts index 72b95047..466e0efc 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -6,7 +6,7 @@ import { relative, resolve, sep } from "node:path"; import { Argument, Command, CommanderError, InvalidArgumentError, Option } from "commander"; -import { description, version } from "../../package.json"; +import { description } from "../../package.json"; import { type Doc, type RankedQuest, @@ -86,6 +86,7 @@ import { type Snapshot, checkout, collect, load, snapshot, under } from "./tree" import { pullRequestStatus, statusLines } from "./status"; import { CCUSAGE_MISSING, daysBefore, usage, usageDependencies, usageLines } from "./usage"; import { waitForActivity, WaitTimeout } from "./wait"; +import { version } from "./version"; import { Worktrees } from "./worktree"; import { HOOK_EVENTS, claudeHook } from "./worktree-hook"; diff --git a/src/cli/version.ts b/src/cli/version.ts new file mode 100644 index 00000000..2d49acb6 --- /dev/null +++ b/src/cli/version.ts @@ -0,0 +1,5 @@ +// The version this build reports. A release binary embeds its tag and a flake build its commit, each +// through `bun build --define QUEST_VERSION=...`; a checkout run from source reports `dev`. +declare const QUEST_VERSION: string | undefined; + +export const version: string = typeof QUEST_VERSION === "string" ? QUEST_VERSION : "dev"; diff --git a/test/cli.test.ts b/test/cli.test.ts index 39de53a2..47d6afe5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -5,7 +5,6 @@ import { join } from "node:path"; import { expect, test, vi } from "vitest"; -import { version } from "../package.json"; import { Tree, run, tempDir } from "./fixture"; test("check reports the document count", () => { @@ -95,7 +94,7 @@ test("init and uninstall print each changed path and nothing else", () => { }); test("version", () => { - expect(run("--version")).toEqual({ code: 0, stdout: `quest ${version}\n`, stderr: "" }); + expect(run("--version")).toEqual({ code: 0, stdout: "quest dev\n", stderr: "" }); }); test("skill lists every skill", () => { From 2005df342822d9ff69bafa0df39fa784d9e73ca8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 13 Nov 2026 15:03:29 -0800 Subject: [PATCH 3/3] fix(release): count a move out of a shipping path as shipping Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 2 +- scripts/release-plan.sh | 2 +- scripts/release-plan.test.sh | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d8abfd4..ebcdb47f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: release=false if [[ $EVENT == pull_request ]]; then build=false - if git diff --name-only "$BASE...$HEAD" | bash scripts/release-plan.sh ships; then build=true; fi + if git diff --name-only --no-renames "$BASE...$HEAD" | bash scripts/release-plan.sh ships; then build=true; fi elif [[ $GITHUB_REF == refs/heads/main ]]; then release=$ships build=$ships diff --git a/scripts/release-plan.sh b/scripts/release-plan.sh index 195f6c9c..4a32a925 100644 --- a/scripts/release-plan.sh +++ b/scripts/release-plan.sh @@ -44,7 +44,7 @@ next() { patch=$((patch + 1)) fi echo "version=$major.$minor.$patch" - if git log --format= --name-only "$range" | ships; then + if git log --format= --name-only --no-renames "$range" | ships; then echo release=true else echo release=false diff --git a/scripts/release-plan.test.sh b/scripts/release-plan.test.sh index ab5884b4..325a36c7 100644 --- a/scripts/release-plan.test.sh +++ b/scripts/release-plan.test.sh @@ -74,6 +74,12 @@ expect $'version=1.4.3\nrelease=true' next v1.4.2 HEAD commit nix/package.nix 'build(nix)!: change' expect $'version=2.0.0\nrelease=true' next v1.4.2 HEAD +# Moving a file out of a shipping path ships, though git would report only the destination. +git tag v2.0.0 +mkdir -p docs && git mv src/c.ts docs/c.ts +git commit --quiet -m 'docs: move' +expect $'version=2.0.1\nrelease=true' next v2.0.0 HEAD + if bash "$plan" next v1.4.2 2>/dev/null; then fail 'a missing head should be a usage error'; fi echo 'release plan: shipping paths and next versions passed'