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..ebcdb47f 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 --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 + 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..4a32a925 --- /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 --no-renames "$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..325a36c7 --- /dev/null +++ b/scripts/release-plan.test.sh @@ -0,0 +1,85 @@ +#!/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 + +# 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' 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", () => {