diff --git a/.github/workflows/auto-version.yml b/.github/workflows/auto-version.yml index e6c7c051..a7f3a871 100644 --- a/.github/workflows/auto-version.yml +++ b/.github/workflows/auto-version.yml @@ -101,10 +101,18 @@ jobs: echo "version=${next}" >> "$GITHUB_OUTPUT" echo "tag=${tag}" >> "$GITHUB_OUTPUT" + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: Update source version shell: bash env: VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.version.outputs.tag }} run: | set -euo pipefail sed -i -E "s/^var version = \"[^\"]+\"/var version = \"${VERSION}\"/" cmd/bomly/main.go @@ -119,7 +127,7 @@ jobs: git diff -- cmd/bomly/main.go npm/package.json server.json - - name: Commit version bump and tag + - name: Commit version bump (commit A) shell: bash env: TAG: ${{ steps.version.outputs.tag }} @@ -130,11 +138,99 @@ jobs: git config user.email "${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" git add cmd/bomly/main.go npm/package.json server.json - git commit -m "chore(release): ${TAG} [skip ci]" - git tag -a "${TAG}" -m "Release ${TAG}" + if git diff --cached --quiet; then + # Rerun after a partial failure: the bump commit is already on + # main, so fall through to the (idempotent) later phases. + echo "version bump for ${TAG} already committed on main; skipping commit A" + else + git commit -m "chore(release): ${TAG} [skip ci]" + git push origin HEAD:main + fi + + - name: Resolve component anchor commit (commit A) + id: anchor + shell: bash + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + # Component tags anchor at commit A, the version bump. On the first + # run that is HEAD. On a rerun after a partial failure, main may + # already carry the pin commit B; the component tags pushed before + # the failure then define A (they all point at it). + anchor="$(git ls-remote --tags origin 'refs/tags/components/*' | + { grep -F "/${TAG}^{}" || true; } | awk '{print $1; exit}')" + if [[ -z "${anchor}" ]]; then + anchor="$(git rev-parse HEAD)" + fi + echo "commit A (component tag anchor): ${anchor}" + echo "commit=${anchor}" >> "$GITHUB_OUTPUT" - git push origin HEAD:main + - name: Tag component modules at commit A + shell: bash + env: + TAG: ${{ steps.version.outputs.tag }} + ANCHOR: ${{ steps.anchor.outputs.commit }} + run: | + set -euo pipefail + # Lockstep: every component module is tagged at the version-bump + # commit A, making `go get @${TAG}` resolvable before the + # root go.mod pins it. The script is idempotent (remote tags at the + # release commit are skipped; a remote tag anywhere else aborts), so + # a rerun after a partial failure completes the tag set. + ./scripts/release-components.sh tag --version "${TAG}" --commit "${ANCHOR}" + + - name: Pin component modules (commit B) + shell: bash + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + # Pin the root go.mod/go.sum to the component tags just pushed and + # commit the result (commit B). The root tag lands on commit B, so + # every root tag carries resolvable pins and no replace directives — + # remote `go install .../cmd/bomly@latest` keeps working. With no + # component modules (or on a rerun that already pinned), this is a + # clean no-op. + ./scripts/release-components.sh pin --version "${TAG}" + git add go.mod go.sum + if git diff --cached --quiet; then + echo "component pins for ${TAG} already in place; skipping commit B" + else + git commit -m "chore(release): pin components ${TAG} [skip ci]" + git push origin HEAD:main + fi + + - name: Tag root module and verify lockstep + shell: bash + env: + TAG: ${{ steps.version.outputs.tag }} + ANCHOR: ${{ steps.anchor.outputs.commit }} + run: | + set -euo pipefail + head_commit="$(git rev-parse HEAD)" + # Lockstep invariant: commit B only touches the root go.mod/go.sum, + # so the components/ tree must be identical between the component + # tags (commit A) and the root tag (commit B). Component module zips + # contain only their own subtree, so tree identity means the tags + # describe exactly the code shipping in this release. + if [[ "${ANCHOR}" != "${head_commit}" ]]; then + if ! git diff --quiet "${ANCHOR}" "${head_commit}" -- components/; then + echo "components/ tree changed between commit A (${ANCHOR}) and commit B (${head_commit}); lockstep invariant violated" >&2 + exit 1 + fi + fi + if existing="$(git rev-parse -q --verify "refs/tags/${TAG}^{commit}")"; then + if [[ "${existing}" != "${head_commit}" ]]; then + echo "tag ${TAG} already exists at ${existing}, not ${head_commit}" >&2 + exit 1 + fi + echo "root tag ${TAG} already exists at ${head_commit}; pushing" + else + git tag -a "${TAG}" -m "Release ${TAG}" + fi git push origin "${TAG}" + ./scripts/release-components.sh verify --version "${TAG}" - name: Start release workflow env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b76206f..e932a837 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,11 +86,12 @@ jobs: id: pins shell: bash # A wave PR lands component modules consumed through the committed - # go.work; the root go.mod cannot require them until the release train - # tags them (make release-components ARGS=--apply) and the pin-bump PR - # merges. Until every component module under components/ is required - # by the root go.mod, a workspace-off build cannot succeed by design, - # so skip the pinned build instead of failing it. + # go.work; the root go.mod cannot require them until the automated + # release train in auto-version.yml tags them at the version-bump + # commit and pushes the pin commit. Until every component module under + # components/ is required by the root go.mod, a workspace-off build + # cannot succeed by design, so skip the pinned build instead of + # failing it. run: | set -euo pipefail missing=0 @@ -155,8 +156,8 @@ jobs: # component module under components/ is workspace-only (consumed via # the committed go.work, not yet pinned in the root go.mod), root tidy # cannot resolve the component import paths, so the root check is - # skipped until the release train tags the components and the - # pin-bump PR lands. + # skipped until auto-version.yml's automated release train tags the + # components and pushes the pin commit. run: | set -euo pipefail skip=0 diff --git a/AGENTS.md b/AGENTS.md index 37ad22b9..4253731f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ Runtime preparation is owned by `internal/engine`: build the filtered registry o - Extracted components will live under `components///` as separate Go modules with their own `go.mod`, tagged per module as `components///vX.Y.Z`. - The committed `go.work` puts the repo in workspace mode for local development; waves add `use ./components/...` entries. Release and pinned builds run with `GOWORK=off` (GoReleaser sets it explicitly; CI's `pinned-build` job verifies the module pins alone still build on pushes to `main`). - Each extraction wave lands as **one atomic PR**: move the code into its component module, add the `use` entry, and keep the root module compiling in the same change. -- `scripts/release-components.sh` (also `make release-components`) is the release train: component modules version in lockstep with the CLI — after a CLI release tag exists, the script tags every component module at that same version (idempotent; unchanged modules get empty releases by design); `--apply` (ARGS="--apply") creates and pushes the tags and prints the root `go get` pin bumps for the follow-up PR. +- The release train is fully automated in `.github/workflows/auto-version.yml` and is a two-commit flow: version-bump commit A on `main` → every `components///vX.Y.Z` tag at A (so `go get @vX.Y.Z` resolves) → root `go.mod`/`go.sum` pin commit B (`[skip ci]`) → root `vX.Y.Z` tag at B → release dispatch. The two commits exist so the root `go.mod` never needs `replace` directives: every root tag carries resolvable pins, preserving remote `go install .../cmd/bomly@latest` — the Go equivalent of Maven parent-version inheritance (one authoritative version; automation propagates it). Lockstep invariant: the `components/` tree is identical between A and B (commit B touches only root `go.mod`/`go.sum`), so unchanged modules get empty releases by design. `scripts/release-components.sh` (also `make release-components` for the dry run) provides the `tag` / `pin` / `verify` subcommands the workflow uses; run them manually only to recover from a partial failure. ### Package Boundaries @@ -277,7 +277,7 @@ Any new user-visible feature needs a smoke case under `test/smoke/` — follow t ## Release -Draft releases are created automatically after merges to `main` from commit prefixes: `feat:` → minor, other → patch, `type!:`/`BREAKING CHANGE:` → major, `[skip release]` → none. Squash titles count. Publishing runs GoReleaser with signed checksums and SLSA provenance; see `dev-docs/RELEASE_CHECKLIST.md`. +Draft releases are created automatically after merges to `main` from commit prefixes: `feat:` → minor, other → patch, `type!:`/`BREAKING CHANGE:` → major, `[skip release]` → none. Squash titles count. The `Auto Version` workflow runs the whole lockstep release train (version-bump commit, component tags, pin commit, root tag, release dispatch). Publishing runs GoReleaser with signed checksums and SLSA provenance; see `dev-docs/RELEASE_CHECKLIST.md`. ## Reference Docs diff --git a/CLAUDE.md b/CLAUDE.md index af534092..dcfa9166 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Runtime preparation is owned by `internal/engine`: build the filtered registry o - Extracted components will live under `components///` as separate Go modules with their own `go.mod`, tagged per module as `components///vX.Y.Z`. - The committed `go.work` puts the repo in workspace mode for local development; waves add `use ./components/...` entries. Release and pinned builds run with `GOWORK=off` (GoReleaser sets it explicitly; CI's `pinned-build` job verifies the module pins alone still build on pushes to `main`). - Each extraction wave lands as **one atomic PR**: move the code into its component module, add the `use` entry, and keep the root module compiling in the same change. -- `scripts/release-components.sh` (also `make release-components`) is the release train: component modules version in lockstep with the CLI — after a CLI release tag exists, the script tags every component module at that same version (idempotent; unchanged modules get empty releases by design); `--apply` (ARGS="--apply") creates and pushes the tags and prints the root `go get` pin bumps for the follow-up PR. +- The release train is fully automated in `.github/workflows/auto-version.yml` and is a two-commit flow: version-bump commit A on `main` → every `components///vX.Y.Z` tag at A (so `go get @vX.Y.Z` resolves) → root `go.mod`/`go.sum` pin commit B (`[skip ci]`) → root `vX.Y.Z` tag at B → release dispatch. The two commits exist so the root `go.mod` never needs `replace` directives: every root tag carries resolvable pins, preserving remote `go install .../cmd/bomly@latest` — the Go equivalent of Maven parent-version inheritance (one authoritative version; automation propagates it). Lockstep invariant: the `components/` tree is identical between A and B (commit B touches only root `go.mod`/`go.sum`), so unchanged modules get empty releases by design. `scripts/release-components.sh` (also `make release-components` for the dry run) provides the `tag` / `pin` / `verify` subcommands the workflow uses; run them manually only to recover from a partial failure. ### Package Boundaries @@ -277,7 +277,7 @@ Any new user-visible feature needs a smoke case under `test/smoke/` — follow t ## Release -Draft releases are created automatically after merges to `main` from commit prefixes: `feat:` → minor, other → patch, `type!:`/`BREAKING CHANGE:` → major, `[skip release]` → none. Squash titles count. Publishing runs GoReleaser with signed checksums and SLSA provenance; see `dev-docs/RELEASE_CHECKLIST.md`. +Draft releases are created automatically after merges to `main` from commit prefixes: `feat:` → minor, other → patch, `type!:`/`BREAKING CHANGE:` → major, `[skip release]` → none. Squash titles count. The `Auto Version` workflow runs the whole lockstep release train (version-bump commit, component tags, pin commit, root tag, release dispatch). Publishing runs GoReleaser with signed checksums and SLSA provenance; see `dev-docs/RELEASE_CHECKLIST.md`. ## Reference Docs diff --git a/Makefile b/Makefile index 15795af2..9e2a1e56 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,11 @@ smoke: fuzz: FUZZTIME="$(FUZZTIME)" scripts/run-fuzz.sh -# Dry-run the component release train (pass ARGS="--apply" to tag and push). +# Dry-run the component release train at the latest release tag. The train +# itself is fully automated in .github/workflows/auto-version.yml; the script's +# `tag`, `pin`, and `verify` subcommands (ARGS="tag --version vX.Y.Z --commit +# ", ARGS="pin --version vX.Y.Z", ARGS="verify --version vX.Y.Z") exist +# for recovery after a partial failure. release-components: ./scripts/release-components.sh $(if $(ARGS),$(ARGS),) diff --git a/dev-docs/CI.md b/dev-docs/CI.md index 29d9045b..5bb41321 100644 --- a/dev-docs/CI.md +++ b/dev-docs/CI.md @@ -14,7 +14,7 @@ its own minimal CI in its own repository. | `Fuzz` | Nightly schedule, manual dispatch | Native Go fuzzing over the `scripts/run-fuzz.sh` target list; uploads minimized failures as artifacts | | `CodeQL` | Pull requests, pushes, schedule | Static analysis for Go and JavaScript | | `SBOM Interoperability` | Schedule, manual dispatch | Binary-driven SBOM export/ingest checks against third-party tools | -| `Auto Version` | Pushes to `main` | Computes the next semver from the squash-commit prefix and creates a draft release tag | +| `Auto Version` | Manual dispatch (from `main`) | Fully automated lockstep release train: version-bump commit, component tags, pin commit, root tag, `Release` dispatch | | `Release` | Release tags | GoReleaser build/publish with signed checksums and SLSA provenance | | `Scorecard`, `Dependency Review`, `Bomly Guard` | Various | Supply-chain posture checks and dogfooding | @@ -53,9 +53,34 @@ regression seed. ## Releases -Merges to `main` create draft releases automatically from conventional-commit -prefixes (`feat:` → minor, other → patch, `type!:`/`BREAKING CHANGE:` → major, -`[skip release]` → none; squash titles count). Publishing runs GoReleaser — -archives, Linux packages, Homebrew/Scoop/WinGet manifests, signed checksums, -and SLSA provenance. See `dev-docs/RELEASE_CHECKLIST.md` and -`docs/INSTALLATION.md` for the artifacts users receive. +Releasing is one `Auto Version` dispatch from `main` (choose `patch`, +`minor`, or `major`). The workflow runs the whole lockstep release train as +a two-commit flow: + +1. Version-bump commit **A** on `main` — `cmd/bomly/main.go`, the npm + wrapper, and the MCP Registry versions. No root tag yet. +2. Every `components///vX.Y.Z` tag at commit A, making + `go get @vX.Y.Z` resolvable. +3. Pin commit **B** on `main` (`[skip ci]`) — the root `go.mod`/`go.sum` + pinned to the just-tagged component versions (`GOWORK=off`). +4. The root tag `vX.Y.Z` at commit B, then a `Release` dispatch. + +The two commits exist because the root `go.mod` carries no `replace` +directives: every root tag has resolvable pins, so remote +`go install github.com/bomly-dev/bomly-cli/cmd/bomly@latest` keeps working — +the Go equivalent of Maven parent-version inheritance (one authoritative +version, propagated by automation). The lockstep invariant is tree identity: +commit B touches only the root `go.mod`/`go.sum`, so the `components/` tree +is identical between A and B, and component module zips (which contain only +their own subtree) describe exactly the code shipping in the CLI release. +The workflow asserts this before tagging the root, and +`scripts/release-components.sh verify` re-checks it against origin. + +Every phase is idempotent, so rerunning a partially failed `Auto Version` +completes the train; `scripts/release-components.sh` exposes the same +`tag` / `pin` / `verify` subcommands for manual recovery. + +Publishing runs GoReleaser — archives, Linux packages, +Homebrew/Scoop/WinGet manifests, signed checksums, and SLSA provenance. See +`dev-docs/RELEASE_CHECKLIST.md` and `docs/INSTALLATION.md` for the artifacts +users receive. diff --git a/dev-docs/RELEASE_CHECKLIST.md b/dev-docs/RELEASE_CHECKLIST.md index 3586c8f2..46215167 100644 --- a/dev-docs/RELEASE_CHECKLIST.md +++ b/dev-docs/RELEASE_CHECKLIST.md @@ -12,7 +12,41 @@ Use this checklist when publishing a tagged Bomly CLI release. ## Release workflow - Run `Auto Version` from `main`, choosing `patch`, `minor`, or `major`. +- `Auto Version` performs the whole lockstep release train in one dispatch, + as a two-commit flow: + 1. Version-bump commit **A** on `main`: `cmd/bomly/main.go` plus the npm + wrapper and MCP Registry versions. No root tag yet. + 2. Every component module tag (`components///vX.Y.Z`) at + commit A. From here, `go get @vX.Y.Z` resolves. + 3. Pin commit **B** on `main` (`[skip ci]`): the root `go.mod`/`go.sum` + pinned to the just-tagged component versions (`GOWORK=off go get` + + `go mod tidy`). + 4. The root tag `vX.Y.Z` at commit B, then a `Release` dispatch. +- Why two commits: the root `go.mod` carries **no `replace` directives**, so + every root tag has resolvable pins and remote + `go install github.com/bomly-dev/bomly-cli/cmd/bomly@latest` keeps + working. The component tags must exist before the pins can resolve — the + Go equivalent of Maven parent-version inheritance: one authoritative + version, propagated by automation. +- Lockstep invariant (asserted by the workflow and by `verify`): the + `components/` tree is identical between commits A and B — commit B touches + only the root `go.mod`/`go.sum`, and component module zips contain only + their own subtree, so the component tags describe exactly the code that + ships in the CLI release. +- Every phase is idempotent: rerunning a partially failed `Auto Version` + completes the missing pieces (component tags already on origin at commit A + are skipped; a tag on origin at any other commit aborts the run and must + be resolved on origin first; an already-present pin skips commit B). For + manual recovery, use + `./scripts/release-components.sh tag --version vX.Y.Z --commit `, + `./scripts/release-components.sh pin --version vX.Y.Z` (then commit), and + check the invariant with + `./scripts/release-components.sh verify --version vX.Y.Z`. If the run + failed after the root tag was pushed, dispatch `Release` manually: + `gh workflow run release.yml --ref vX.Y.Z`. - Wait for `Release` to finish. +- Run `./scripts/release-components.sh verify --version vX.Y.Z` if you want an + explicit post-release lockstep check (the workflow already ran it). - Review the published GitHub release: - `bomly` archives exist for Linux, macOS, and Windows on `amd64` and `arm64`. - `bomly-lite` archives exist for the same platforms. diff --git a/scripts/release-components.sh b/scripts/release-components.sh index 97982fb4..8320f7fa 100755 --- a/scripts/release-components.sh +++ b/scripts/release-components.sh @@ -1,160 +1,322 @@ #!/usr/bin/env bash -# release-components.sh — release-train helper for in-repo component modules. +# release-components.sh — lockstep release-train tool for in-repo component modules. # # Component modules live under components/// with their own go.mod # and are versioned in LOCKSTEP with the CLI: every component module is tagged # at the CLI's release version, whether or not it changed. One version number -# describes the whole repository; empty component releases are deliberate and -# accepted in exchange for that simplicity. +# describes the whole repository — the Go equivalent of Maven parent-version +# inheritance: the version is authoritative in one place and automation +# propagates it. Empty component releases are deliberate and accepted in +# exchange for that simplicity. # # components///vX.Y.Z (X.Y.Z = the CLI release version) # +# The root go.mod pins released component versions and carries NO replace +# directives, so remote `go install .../cmd/bomly@latest` keeps working at +# every root tag. That constraint forces a two-commit release train, fully +# automated in .github/workflows/auto-version.yml: +# +# commit A version bump on main (cmd/bomly/main.go etc.); NOT root-tagged +# tags components///vX.Y.Z for every module, at commit A +# (now `go get @vX.Y.Z` resolves) → `tag` +# commit B root go.mod/go.sum pinned to the new tags, [skip ci] → `pin` +# root tag vX.Y.Z at commit B, then release.yml dispatch +# +# Component module zips contain only their own subtree, so commit B (root +# go.mod/go.sum only) never changes what the component tags describe. The +# lockstep invariant is therefore TREE IDENTITY, not commit identity: the +# components/ tree must be identical between the component tags (at A) and +# the root tag (at B). `verify` checks exactly that. +# +# Subcommands (used by auto-version.yml; run manually only for recovery after +# a partial failure): +# +# tag --version vX.Y.Z --commit +# Create and push an annotated components///vX.Y.Z tag for +# every component module found in 's tree. Idempotent, remote-first: +# a tag already on origin at is skipped; a tag on origin at any +# other commit aborts the train (resolve it on origin first); a stale +# local-only tag at the wrong commit aborts with a delete hint; a +# local-only tag at the right commit (partial earlier run) is pushed. +# +# pin --version vX.Y.Z +# With the workspace off, `go get` every component module in HEAD's tree +# at vX.Y.Z and run `go mod tidy`, so the root go.mod/go.sum pin the +# just-tagged releases. No commit — the caller commits (commit B). +# Already-pinned modules make this a no-op, so reruns are safe. +# +# verify --version vX.Y.Z +# Check the lockstep invariant for a released version: every component +# tag exists on origin and peels to a commit whose components/ tree is +# identical to the root tag's components/ tree. +# # Usage: # ./scripts/release-components.sh # dry run at the latest CLI tag # ./scripts/release-components.sh --version v1.2.3 # dry run at an explicit version -# ./scripts/release-components.sh --apply # create + push the tags -# -# Dry run (default): resolve the release version (latest root v* tag unless -# --version is given), then print the `git tag` / `git push` commands for every -# component module missing that tag, plus the root-module `go get` pin bumps. +# ./scripts/release-components.sh tag --version v1.2.3 --commit +# ./scripts/release-components.sh pin --version v1.2.3 +# ./scripts/release-components.sh verify --version v1.2.3 # -# --apply: create and push those annotated tags. Modules whose tag already -# exists ON ORIGIN are skipped (the remote is the source of truth, so a rerun -# after a partial failure pushes any local-only tag instead of skipping it), -# making the script idempotent. The root pin bumps are printed, not executed — -# they land through a normal PR. -# -# Intended flow: run after the CLI release tag exists (auto-version.yml), then -# open the pin-bump PR. +# The dry run prints, without changing anything, what the `tag` and `pin` +# phases would do at the given (default: latest) root release tag. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${repo_root}" -apply=false +die() { + echo "error: $*" >&2 + exit 1 +} + +usage() { + echo "usage: $0 [tag|pin|verify] [--version vX.Y.Z] [--commit ]" >&2 + echo " $0 [--version vX.Y.Z] # dry run" >&2 + exit 2 +} + +subcommand="dry-run" +case "${1:-}" in + tag | pin | verify) + subcommand="$1" + shift + ;; +esac + version="" +commit="" while [[ $# -gt 0 ]]; do case "$1" in - --apply) - apply=true - shift - ;; --version) version="${2:-}" - if [[ -z "${version}" ]]; then - echo "error: --version requires a value (vX.Y.Z)" >&2 - exit 2 - fi + [[ -n "${version}" ]] || die "--version requires a value (vX.Y.Z)" + shift 2 + ;; + --commit) + commit="${2:-}" + [[ -n "${commit}" ]] || die "--commit requires a value (sha)" shift 2 ;; + --apply) + die "--apply was replaced by subcommands: $0 tag --version vX.Y.Z --commit , then $0 pin --version vX.Y.Z" + ;; *) - echo "usage: $0 [--apply] [--version vX.Y.Z]" >&2 - exit 2 + usage ;; esac done -if [[ -z "${version}" ]]; then - # Latest CLI release tag. Root tags are plain vX.Y.Z; component tags carry - # a path prefix, so this glob cannot match them. - version="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1)" +require_version() { + [[ -n "${version}" ]] || die "${subcommand} requires --version vX.Y.Z" + [[ "${version}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || + die "version ${version} is not of the form vX.Y.Z" +} + +# resolve_default_version fills in the latest CLI release tag when the dry run +# is invoked without --version. Root tags are plain vX.Y.Z; component tags +# carry a path prefix, so this glob cannot match them. +resolve_default_version() { if [[ -z "${version}" ]]; then - echo "error: no CLI release tag found; pass --version vX.Y.Z" >&2 - exit 1 + version="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1)" + [[ -n "${version}" ]] || die "no CLI release tag found; pass --version vX.Y.Z" fi -fi +} -if [[ ! "${version}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "error: version ${version} is not of the form vX.Y.Z" >&2 - exit 1 -fi - -# Component tags must point at the CLI release commit, not HEAD: the local -# branch may have advanced past the release tag (or --version may select an -# older release), and lockstep means the component tag captures exactly the -# code that shipped in that CLI release. -release_commit="$(git rev-parse -q --verify "refs/tags/${version}^{commit}")" || { - echo "error: release tag ${version} not found locally; fetch tags first (git fetch --tags)" >&2 - exit 1 +# list_modules prints the component module directories found in a commit's +# tree (never the working tree: the checkout may have drifted from the commit +# being tagged or verified). +list_modules() { # $1 = commit + git ls-tree -r --name-only "$1" -- components/ | + { grep -E '^components/[^/]+/[^/]+/go\.mod$' || true; } | + sed 's#/go\.mod$##' } -# Collect component module directories from the RELEASE COMMIT, not the -# working tree: with --version selecting an older release, HEAD's set of -# components///go.mod files may differ from what shipped. -modules=() -while IFS= read -r gomod; do - modules+=("$(dirname "${gomod}")") -done < <(git ls-tree -r --name-only "${release_commit}" -- components/ | - grep -E '^components/[^/]+/[^/]+/go\.mod$' || true) - -if [[ ${#modules[@]} -eq 0 ]]; then - echo "no component modules found under components/*/*/ — nothing to release" >&2 - exit 0 -fi - -echo "# release version: ${version} (commit ${release_commit})" -bump_cmds=() -for module in "${modules[@]}"; do - tag="${module}/${version}" - # Derive the module path from the go.mod AS OF THE RELEASE COMMIT (not the - # checkout), so /v2+ major-version modules pin correctly and the pin - # commands describe released code by construction. - module_path="$(git show "${release_commit}:${module}/go.mod" | - awk '$1 == "module" { print $2; exit }')" - if [[ -z "${module_path}" ]]; then - echo "error: ${module}/go.mod at ${version} has no module directive" >&2 - exit 1 - fi - bump_cmds+=("go get ${module_path}@${version}") - - # Idempotent reruns must survive a partial failure between local tag - # creation and push: the REMOTE is the source of truth for "already - # released". A local-only tag is verified against the release commit - # (a stale local tag is an error, never silently pushed) and pushed. - # A pre-existing remote tag is verified too: prefer the peeled ^{} - # commit (annotated tags), falling back to the unpeeled ref for - # lightweight tags. A remote tag at the wrong commit aborts the train — - # it must never silently ride into the pin bumps. - remote_tag_commit="$(git ls-remote --tags origin "refs/tags/${tag}^{}" | awk '{print $1; exit}')" - if [[ -z "${remote_tag_commit}" ]]; then - remote_tag_commit="$(git ls-remote --tags origin "refs/tags/${tag}" | awk '{print $1; exit}')" +# module_path_at reads the `module` directive of a component go.mod as of a +# commit, validating that the module is well-formed before it gets a tag or a +# pin. +module_path_at() { # $1 = commit, $2 = module dir + git show "$1:$2/go.mod" | awk '$1 == "module" { print $2; exit }' +} + +# remote_tag_commit prints the commit a tag on origin points at, preferring +# the peeled ^{} entry (annotated tags) and falling back to the unpeeled ref +# (lightweight tags). Prints nothing when the tag is not on origin. +remote_tag_commit() { # $1 = tag + local peeled + peeled="$(git ls-remote --tags origin "refs/tags/$1^{}" | awk '{print $1; exit}')" + if [[ -z "${peeled}" ]]; then + peeled="$(git ls-remote --tags origin "refs/tags/$1" | awk '{print $1; exit}')" fi - if [[ -n "${remote_tag_commit}" ]]; then - if [[ "${remote_tag_commit}" != "${release_commit}" ]]; then - echo "error: remote tag ${tag} points at ${remote_tag_commit}, not release commit ${release_commit}; resolve the tag on origin before releasing" >&2 - exit 1 + printf '%s' "${peeled}" +} + +# components_tree_matches succeeds when two commits have byte-identical +# components/ trees — the lockstep invariant between the component tags +# (commit A) and the root tag (commit B). +components_tree_matches() { # $1 = commit, $2 = commit + git diff --quiet "$1" "$2" -- components/ +} + +# tag_module creates+pushes (mode=apply) or prints (mode=print) one component +# tag at ${release_commit}. Idempotent reruns must survive a partial failure +# between local tag creation and push: the REMOTE is the source of truth for +# "already released". A local-only tag is verified against the release commit +# (a stale local tag is an error, never silently pushed) and pushed. A +# pre-existing remote tag is verified too; a remote tag at the wrong commit +# aborts the train — it must never silently ride into a release. +tag_module() { # $1 = module dir, $2 = mode (apply|print) + local module="$1" mode="$2" tag module_path remote_commit local_commit + tag="${module}/${version}" + + module_path="$(module_path_at "${release_commit}" "${module}")" + [[ -n "${module_path}" ]] || + die "${module}/go.mod at ${release_commit} has no module directive" + + remote_commit="$(remote_tag_commit "${tag}")" + if [[ -n "${remote_commit}" ]]; then + if [[ "${mode}" == "print" ]]; then + # The dry run inspects a released version, where component tags + # (commit A) legitimately differ from the root tag (commit B), so + # report tree identity rather than commit equality. + if ! git cat-file -e "${remote_commit}^{commit}" 2>/dev/null; then + echo "# ${module}: ${tag} on origin at ${remote_commit} (not available locally; git fetch --tags origin to compare trees)" + elif components_tree_matches "${remote_commit}" "${release_commit}"; then + echo "# ${module}: ${tag} already on origin at ${remote_commit} (components/ tree matches ${version}), nothing to do" + else + echo "# ${module}: WARNING ${tag} on origin at ${remote_commit} has a DIFFERENT components/ tree than ${version} — run 'verify' for details" + fi + return 0 + fi + if [[ "${remote_commit}" != "${release_commit}" ]]; then + die "remote tag ${tag} points at ${remote_commit}, not release commit ${release_commit}; resolve the tag on origin before releasing" fi echo "# ${module}: ${tag} already on origin at ${release_commit}, skipping" - continue + return 0 fi - local_tag_commit="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}" || true)" - if [[ -n "${local_tag_commit}" && "${local_tag_commit}" != "${release_commit}" ]]; then - echo "error: local tag ${tag} points at ${local_tag_commit}, not release commit ${release_commit}; delete it (git tag -d ${tag}) and rerun" >&2 - exit 1 + + local_commit="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}" || true)" + if [[ -n "${local_commit}" && "${local_commit}" != "${release_commit}" ]]; then + die "local tag ${tag} points at ${local_commit}, not release commit ${release_commit}; delete it (git tag -d ${tag}) and rerun" fi - if ${apply}; then - if [[ -z "${local_tag_commit}" ]]; then - git tag -a "${tag}" -m "Release ${tag}" "${release_commit}" - else - echo "# ${module}: local tag ${tag} exists but was never pushed, pushing" - fi - git push origin "${tag}" - echo "tagged and pushed ${tag} at ${release_commit}" - else - if [[ -z "${local_tag_commit}" ]]; then + + if [[ "${mode}" == "print" ]]; then + if [[ -z "${local_commit}" ]]; then echo "git tag -a ${tag} -m 'Release ${tag}' ${release_commit}" else echo "# ${module}: local tag ${tag} exists but is not on origin" fi echo "git push origin ${tag}" + return 0 fi -done -if [[ ${#bump_cmds[@]} -gt 0 ]]; then - echo "" - echo "# root-module pin bumps (run from the repository root, then 'go mod tidy'):" - for cmd in "${bump_cmds[@]}"; do - echo "${cmd}" - done -fi + if [[ -z "${local_commit}" ]]; then + git tag -a "${tag}" -m "Release ${tag}" "${release_commit}" + else + echo "# ${module}: local tag ${tag} exists but was never pushed, pushing" + fi + git push origin "${tag}" + echo "tagged and pushed ${tag} at ${release_commit}" +} + +cmd_tag() { # $1 = mode (apply|print) + local mode="$1" module found=0 + while IFS= read -r module; do + [[ -n "${module}" ]] || continue + found=1 + tag_module "${module}" "${mode}" + done < <(list_modules "${release_commit}") + if [[ "${found}" -eq 0 ]]; then + echo "no component modules under components/*/*/ at ${release_commit} — nothing to tag" + fi +} + +case "${subcommand}" in + tag) + require_version + [[ -n "${commit}" ]] || die "tag requires --commit (the version-bump commit A)" + release_commit="$(git rev-parse -q --verify "${commit}^{commit}")" || + die "commit ${commit} not found; fetch first" + echo "# release version: ${version} (commit ${release_commit})" + cmd_tag apply + ;; + + pin) + require_version + # Pin the root module to the component tags just pushed at commit A. + # GOWORK=off so `go get` and `go mod tidy` resolve the published tags + # instead of the committed workspace. Freshly pushed tags may not have + # reached proxy.golang.org or sum.golang.org yet, so default to a + # direct origin fetch and skip the checksum database for this + # repository's own component modules (go.sum still records the hashes + # computed from the download). Callers may override either variable. + export GOPROXY="${GOPROXY:-direct}" + export GONOSUMDB="${GONOSUMDB:-github.com/bomly-dev/bomly-cli/components}" + pinned=0 + while IFS= read -r module; do + [[ -n "${module}" ]] || continue + module_path="$(module_path_at HEAD "${module}")" + [[ -n "${module_path}" ]] || + die "${module}/go.mod at HEAD has no module directive" + echo "pinning ${module_path}@${version}" + GOWORK=off go get "${module_path}@${version}" + pinned=1 + done < <(list_modules HEAD) + if [[ "${pinned}" -eq 0 ]]; then + echo "no component modules under components/*/*/ at HEAD — nothing to pin" + exit 0 + fi + GOWORK=off go mod tidy + echo "pinned component modules at ${version}; commit the root go.mod/go.sum changes (commit B)" + ;; + + verify) + resolve_default_version + require_version + root_commit="$(remote_tag_commit "${version}")" + [[ -n "${root_commit}" ]] || die "root tag ${version} is not on origin" + git cat-file -e "${root_commit}^{commit}" 2>/dev/null || + die "commit ${root_commit} (root tag ${version}) is not available locally; run 'git fetch --tags origin' and rerun" + checked=0 + while IFS= read -r module; do + [[ -n "${module}" ]] || continue + tag="${module}/${version}" + component_commit="$(remote_tag_commit "${tag}")" + [[ -n "${component_commit}" ]] || + die "component tag ${tag} is not on origin (root tag ${version} is at ${root_commit}); rerun: $0 tag --version ${version} --commit " + git cat-file -e "${component_commit}^{commit}" 2>/dev/null || + die "commit ${component_commit} (tag ${tag}) is not available locally; run 'git fetch --tags origin' and rerun" + if ! components_tree_matches "${component_commit}" "${root_commit}"; then + die "components/ tree differs between ${tag} (at ${component_commit}) and root tag ${version} (at ${root_commit}); the lockstep invariant is broken" + fi + echo "ok: ${tag} at ${component_commit} (components/ tree matches root tag)" + checked=$((checked + 1)) + done < <(list_modules "${root_commit}") + echo "verify ${version}: ${checked} component tag(s) in lockstep with the root tag at ${root_commit}" + ;; + + dry-run) + [[ -z "${commit}" ]] || die "--commit is only valid with the tag subcommand" + resolve_default_version + require_version + release_commit="$(git rev-parse -q --verify "refs/tags/${version}^{commit}")" || + die "release tag ${version} not found locally; fetch tags first (git fetch --tags)" + echo "# release version: ${version} (root tag commit ${release_commit})" + echo "# dry run — tag phase (auto-version.yml runs this at the version-bump commit A):" + cmd_tag print + echo "" + echo "# dry run — pin phase ('pin --version ${version}' runs these with GOWORK=off, then 'go mod tidy'):" + pins=0 + while IFS= read -r module; do + [[ -n "${module}" ]] || continue + module_path="$(module_path_at "${release_commit}" "${module}")" + [[ -n "${module_path}" ]] || + die "${module}/go.mod at ${version} has no module directive" + echo "go get ${module_path}@${version}" + pins=1 + done < <(list_modules "${release_commit}") + if [[ "${pins}" -eq 0 ]]; then + echo "# (no component modules — nothing to pin)" + fi + ;; +esac