diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f627abcc1..35b3c6f52b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,22 @@ name: CI # The fork's value is being able to cut a reliable release when it matters, so # gate every PR (and main) on the pieces a release depends on: a clean install, -# the TypeScript build, and a real native Linux kernel build validated by the -# kernel walker-parity suites. +# the TypeScript build, a real native Linux kernel build validated by the +# kernel walker-parity suites, and the whole test suite. The Release +# workflow calls this one too, on the commit it is about to publish. on: pull_request: push: branches: [main] + workflow_call: permissions: contents: read concurrency: - group: ci-${{ github.ref }} + # Called from Release, github.workflow is "Release", so a release's test run + # and a push to main never cancel each other. + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -38,3 +42,13 @@ jobs: # Fail if the native kernel is missing or diverges from the reference # walker — CODEGRAPH_KERNEL_EXPECT=1 turns a missing binary into an error. run: CODEGRAPH_KERNEL_EXPECT=1 npx vitest run __tests__/kernel-*.test.ts + + - name: Test suite + # Three daemon-older-version cases fail on any fork build by design: + # they compare plain X.Y.Z releases, and ours carry -fmagent.N + # (FORK.md). They are skipped by name because each workspace project's + # own exclude list overrides --exclude. The kernel suites run again + # here, which takes seconds. + run: >- + npx vitest run -t + "^(?!a launcher meeting a daemon of another version \(#2335\) (finds an older daemon|keeps the older daemon|stops a daemon from an older install))" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbd762778f..7c588322f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,14 +3,20 @@ name: Release # FM-Agent maintenance fork — trimmed from upstream v1.5.0's release workflow. # # Manually triggered ("Run workflow") from the `main` branch only. On trigger it: -# 1. builds the native Rust extraction kernel per platform (the `kernel` -# matrix, unchanged from upstream — required as of 1.5.0), -# 2. bundles a self-contained per-OS archive (mac/linux/windows, arm64/x64), -# 3. creates the GitHub Release (tag v) pinned to the built commit, -# with all archives + SHA256SUMS. +# 1. refuses a version already released from another commit (`preflight`), +# 2. runs the CI workflow — build, kernel gate and the test suite — on the +# commit being released (`test`), alongside +# 3. the native Rust extraction kernel per platform (the `kernel` matrix, +# unchanged from upstream — required as of 1.5.0), +# 4. bundles a self-contained per-OS archive (mac/linux/windows, arm64/x64), +# 5. creates the GitHub Release (tag v) pinned to the built commit, +# with all archives + SHA256SUMS, and notes listing the pull requests +# merged since the previous release. # # Before triggering: bump package.json's version to the target # (X.Y.Z-fmagent.N) and make sure the intended patches are on this branch. +# A version is published once: `preflight` stops a release whose tag already +# points at another commit, so a forgotten bump cannot replace its bundles. # # Removed vs upstream: npm publish, npmmirror sync, build-provenance attestation, # and the CHANGELOG / package-lock auto-commit-and-push steps — this fork @@ -34,6 +40,29 @@ permissions: contents: write # create the GitHub Release + tag jobs: + # Runs first and gates every other job. The version comes from package.json; + # its tag either does not exist yet, or points at this very commit (a re-run + # after a failed upload). Anything else means the bump was forgotten. + preflight: + # Same rule as the kernel job below. + if: github.ref == 'refs/heads/main' || inputs.prerelease + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + steps: + - uses: actions/checkout@v6 + - name: Resolve version + id: ver + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + - name: Refuse a version already released from another commit + run: bash scripts/check-release-tag.sh "v${{ steps.ver.outputs.version }}" "$GITHUB_SHA" + + # The gate every pull request passes, run again on the commit being released: + # a prerelease is often cut from a branch no pull request has tested yet. + test: + needs: preflight + uses: ./.github/workflows/ci.yml + # Native extraction-kernel prebuilds — REQUIRED as of 1.5.0 (the kernel is the # release's headline, not an optional extra). Each leg builds its platform's # codegraph-kernel.node natively; the vendored-grammar-C languages @@ -46,6 +75,7 @@ jobs: # anyone's upgrade target, and being able to build one from the branch under # review is the point — main stays untouched while the build is tried. if: github.ref == 'refs/heads/main' || inputs.prerelease + needs: preflight strategy: fail-fast: false matrix: @@ -78,7 +108,7 @@ jobs: # Same rule as the kernel job above. if: github.ref == 'refs/heads/main' || inputs.prerelease runs-on: ubuntu-latest - needs: kernel + needs: [preflight, test, kernel] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -88,10 +118,6 @@ jobs: - name: Ensure zip/unzip run: sudo apt-get update -qq && sudo apt-get install -y -qq zip unzip - - name: Resolve version - id: ver - run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - - name: Download kernel prebuilds # All legs are required (see the kernel job), so every target's # /codegraph-kernel.node must be present in release/kernel/. @@ -134,6 +160,11 @@ jobs: # The base is NOT named here. It moves every sync, a literal goes stale # silently, and one did: every release from v1.6.0 on said "v1.5.0". # FORK.md carries the pinned base, and it is read from this same commit. + # What changed comes from GitHub's generated notes: the pull requests + # merged since the previous full release, with their authors and a + # compare link. + env: + GH_TOKEN: ${{ github.token }} run: | { echo 'FM-Agent maintenance fork of [codegraph](https://github.com/colbymchenry/codegraph).' @@ -141,6 +172,10 @@ jobs: echo 'Self-contained per-OS bundles (mac/linux/windows, arm64/x64) carrying the' echo 'native Rust extraction kernel. FORK.md at this tag records the pinned' echo 'upstream base and every patch carried on top of it.' + echo + gh api "repos/${{ github.repository }}/releases/generate-notes" \ + -f tag_name="v${{ needs.preflight.outputs.version }}" \ + -f target_commitish="$GITHUB_SHA" --jq .body } > notes.md if [ "${{ inputs.prerelease }}" = "true" ]; then { @@ -152,7 +187,7 @@ jobs: echo 'build on its own. Install it by exact version:' echo echo '```' - echo "CODEGRAPH_VERSION=v${{ steps.ver.outputs.version }} bash <(curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/install.sh)" + echo "CODEGRAPH_VERSION=v${{ needs.preflight.outputs.version }} bash <(curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/install.sh)" echo '```' echo echo "Built from \`${{ github.ref_name }}\` at \`${{ github.sha }}\`." @@ -164,8 +199,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - TAG="v${{ steps.ver.outputs.version }}" - # Idempotent: create once, otherwise (re-run) refresh assets. + TAG="v${{ needs.preflight.outputs.version }}" + # Idempotent: create once, otherwise (re-run) refresh assets. Only a + # re-run of the same commit gets here with the release in place: + # preflight has refused a tag on any other commit. # --target pins the tag to the commit these artifacts were built from, # so a main that advanced during the matrix build can't leave the tag # and the bundles sourced from different commits. diff --git a/FORK.md b/FORK.md index f7ffc6aae5..8b12dd3445 100644 --- a/FORK.md +++ b/FORK.md @@ -81,8 +81,9 @@ daemon left over from an *older* install, but `isOlderRelease` (`src/mcp/version compares plain `X.Y.Z` releases only, so a fork build neither stops an older daemon nor is stopped by a newer one — it reports a version mismatch instead. Three end-to-end cases in `__tests__/daemon-older-version.test.ts` fail on a fork build for -that reason (they pass on upstream); CI does not run them. FM-Agent runs `init` and -`index` from the command line and starts no daemon, so it is unaffected. +that reason (they pass on upstream); CI runs the whole suite but skips these three +by name. FM-Agent runs `init` and `index` from the command line and starts no +daemon, so it is unaffected. **All install/upgrade entry points point at this fork,** so a fork install never silently escapes back to upstream: `install.sh` / `install.ps1` (`REPO` / `$repo`) diff --git a/scripts/check-release-tag.sh b/scripts/check-release-tag.sh new file mode 100755 index 0000000000..4a6466e223 --- /dev/null +++ b/scripts/check-release-tag.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Refuse to release a version that is already released from another commit. +# +# usage: scripts/check-release-tag.sh +# +# A published version must never change: whoever installs v1.6.2-fmagent.1 +# gets the same bundles every time. So a tag that already points at another +# commit means package.json's version was not bumped, and the release stops +# before anything is built. The same tag on the same commit is a re-run (an +# upload that failed part way) and may go ahead. +set -euo pipefail + +tag=$1 +commit=$2 + +refs=$(git ls-remote --tags origin "refs/tags/$tag" "refs/tags/$tag^{}") +if [ -z "$refs" ]; then + echo "$tag is not released yet." + exit 0 +fi + +# An annotated tag also lists "^{}", the commit it points at. +existing=$(grep -F "refs/tags/$tag^{}" <<<"$refs" | cut -f1 || true) +[ -n "$existing" ] || existing=$(cut -f1 <<<"$refs") + +if [ "$existing" = "$commit" ]; then + echo "$tag already points at $commit: re-running the release of the same commit." + exit 0 +fi + +echo "::error::$tag is already released from $existing, not $commit. Bump the version in package.json, ui/package.json and package-lock.json before releasing." +exit 1