diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index eac5575..0000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,48 +0,0 @@ -# Automated versioning. On every push to main, release-please reads the conventional -# commits since the last release and maintains a rolling "release PR" that bumps the -# version + regenerates the CHANGELOG. Merging that PR cuts the release (tag + GitHub -# release) — so YOU decide *when* to release by merging, at a milestone. `release-type: -# simple` = no language package to publish; it just tracks the version + changelog. -# -# Uses the default GITHUB_TOKEN: simplest, no PAT/secret. Trade-off — PRs opened by the -# default token don't trigger other workflows, so the release PR's own gate checks won't -# auto-run. That's fine for a version-bump/changelog PR you review before merging. -name: release-please - -on: - push: - branches: [main] - -permissions: - contents: write # create the release PR, tags, and the GitHub release - pull-requests: write - -jobs: - release-please: - runs-on: ubuntu-latest - steps: - - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 - id: release - with: - config-file: release-please-config.json - manifest-file: .release-please-manifest.json - - # release-please tags `vX.Y.Z` but does NOT manage the floating major alias. Move - # `v1` onto each new release so consumers pinned to `@v1` pick up minors/patches. - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - if: ${{ steps.release.outputs.release_created }} - with: - fetch-depth: 0 - - name: Move the floating major tag to the new release - if: ${{ steps.release.outputs.release_created }} - env: - TAG: ${{ steps.release.outputs.tag_name }} - MAJOR: ${{ steps.release.outputs.major }} - run: | - set -euo pipefail - git fetch --tags --force origin - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # `^{}` dereferences an annotated tag to its commit before re-pointing v. - git tag -f "v${MAJOR}" "${TAG}^{}" - git push origin -f "refs/tags/v${MAJOR}" diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index 9965a34..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "2.3.0" -} diff --git a/README.md b/README.md index 8b41110..4898431 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,19 @@ consumer pinning `@v1` rides non-breaking updates while `@v1.2.0` stays frozen. Pin by SHA for maximum reproducibility (Renovate bumps it) or by `@v1` for convenience. `CHANGELOG.md` records what each tag moved. +Cut a release with one command from a clean `main`: + +```bash +bash scripts/cut-release.sh minor # or major | patch | X.Y.Z (--dry-run to preview) +``` + +It reads the current version from the latest tag (the only source of truth), builds +the `CHANGELOG` section from the conventional commits since that tag, tags + pushes, +creates the GitHub release, and advances the `vX` alias — deterministically, in one +auditable step. It **refuses** a non-major bump when it sees a breaking commit, so +semver can't silently slip. (This replaced release-please, whose separate manifest +state could desync from the tags.) + ## Design Two ideas do most of the work: diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f5db137..c9c43a9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -73,6 +73,7 @@ fails the verb instead of going unchecked. - `ruleset_guard.py` — per-entry, tightening-aware anti-gaming check. - `npm-audit-ratchet.mjs` — ratcheted `npm audit` for the ts `audit` verb: fails on any critical not in `.audit-allowlist.json` and on stale entries, so accepted CVE debt can only shrink (npm audit has no native per-advisory ignore). Degrades to plain `npm audit --audit-level=critical` with no allowlist. Reads the report from stdin. +- `cut-release.sh` — cut a release in one deterministic command (version from the latest tag → CHANGELOG from conventional commits since it → tag + push → GitHub release → advance the `vN` alias; refuses a non-major bump on a breaking commit). Replaced release-please. Run locally on a clean `main`. - `foundry-init.sh` — one-shot repo scaffold. - `setup-labels.sh` — create the GitHub labels the workflows + ticket state machine need (agent:ready/working/blocked, align, ruleset-change, autofix). Idempotent; run by `foundry-init`. diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index b87c1a7..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "packages": { - ".": { - "release-type": "simple", - "package-name": "foundry", - "changelog-path": "CHANGELOG.md", - "include-component-in-tag": false, - "bump-minor-pre-major": false - } - } -} diff --git a/scripts/cut-release.sh b/scripts/cut-release.sh new file mode 100644 index 0000000..6e0a0e0 --- /dev/null +++ b/scripts/cut-release.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# cut-release.sh — cut a Foundry release in one deterministic, auditable command. +# +# Replaces release-please: no rolling release PR, no manifest state to desync, no +# special repo settings, no bolt-on alias mover. You run it locally when you decide +# to ship. It reads the current version from the latest git tag (the only source of +# truth), so it can't drift. +# +# Usage: bash scripts/cut-release.sh [--dry-run] +# +# Preconditions: on `main`, clean tree, in sync with origin/main. Then it: +# 1. computes the new version from the latest vX.Y.Z tag, +# 2. builds a CHANGELOG section from conventional commits since that tag +# (grouped feat/fix/other; BREAKING flagged) — only lastTag..HEAD, so the +# rewritten-history duplicates release-please produced can't recur, +# 3. commits the CHANGELOG bump, tags vX.Y.Z, pushes both, +# 4. creates the GitHub release, +# 5. advances the floating major alias vN -> vX.Y.Z. +set -euo pipefail + +BUMP="${1:?usage: cut-release.sh [--dry-run]}" +DRY_RUN=false +[ "${2:-}" = "--dry-run" ] && DRY_RUN=true + +die() { echo "cut-release: $*" >&2; exit 1; } +step() { echo "== $* =="; } +run() { if $DRY_RUN; then echo " [dry-run] $*"; else eval "$*"; fi; } + +require_clean_main() { + [ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || die "not on main (checkout main first)" + if ! git diff --quiet || ! git diff --cached --quiet; then die "working tree not clean"; fi + git fetch -q origin main + [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || die "main is not in sync with origin/main" +} + +current_version() { git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' | sed 's/^v//'; } + +next_version() { # + local bump="$2" IFS=. + # shellcheck disable=SC2086 # deliberate: split $1 on IFS=. into major/minor/patch + set -- $1 ; local maj=$1 min=$2 pat=$3 + case "$bump" in + major) echo "$((maj+1)).0.0" ;; + minor) echo "$maj.$((min+1)).0" ;; + patch) echo "$maj.$min.$((pat+1))" ;; + esac +} + +# Resolve the target version + guard breaking-vs-bump. +resolve_version() { # + local cur="$1" arg="$2" + case "$arg" in + major|minor|patch) next_version "$cur" "$arg" ;; + [0-9]*.[0-9]*.[0-9]*) echo "$arg" ;; + *) die "bump must be major | minor | patch | X.Y.Z" ;; + esac +} + +has_breaking() { # — 0 if any breaking commit since lasttag + git log "$1"..HEAD --no-merges --format='%s%n%b' | grep -qE '^[a-z]+([(].+[)])?!:|BREAKING CHANGE' +} + +# Emit one "### " block for commits whose subject matches <regex>, else nothing. +# awk regexes use [(] not \( — gawk warns on \( and other awks may treat it differently. +group() { # <lasttag> <regex> <title> + local body + body=$(git log "$1"..HEAD --no-merges --format='%h%x09%s' \ + | awk -F'\t' -v re="$2" '$2 ~ re { sub(/^[a-z]+([(].+[)])?!?: */, "", $2); print "* " $2 " (" $1 ")" }') + [ -n "$body" ] && printf '\n### %s\n\n%s\n' "$3" "$body" +} + +build_changelog_section() { # <lasttag> <newver> <repo> + local last="$1" ver="$2" repo="$3" date + date=$(date +%Y-%m-%d) + printf '## [%s](https://github.com/%s/compare/%s...v%s) (%s)\n' "$ver" "$repo" "$last" "$ver" "$date" + has_breaking "$last" && group "$last" '^[a-z]+([(].+[)])?!:' '⚠ BREAKING CHANGES' + group "$last" '^feat([(].+[)])?!?:' 'Features' + group "$last" '^fix([(].+[)])?!?:' 'Bug Fixes' + echo +} + +# Insert the new section directly above the first existing "## [" entry. +insert_changelog() { # <section-file> + awk -v f="$1" ' + !done && /^## \[/ { while ((getline l < f) > 0) print l; done=1 } + { print } + END { if (!done) { while ((getline l < f) > 0) print l } } + ' CHANGELOG.md > CHANGELOG.md.new && mv CHANGELOG.md.new CHANGELOG.md +} + +main() { + require_clean_main + local repo cur ver last tag + repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) + cur=$(current_version) + ver=$(resolve_version "$cur" "$BUMP") + last="v$cur" ; tag="v$ver" + step "release $cur -> $ver" + + # Enforce semver: a breaking change since the last tag demands a major bump. + if has_breaking "$last" && [ "${ver%%.*}" = "${cur%%.*}" ]; then + die "breaking commits since $last but $ver is not a major bump — use 'major' or an explicit X.Y.Z" + fi + + build_changelog_section "$last" "$ver" "$repo" > /tmp/cut-release-section.md + echo "--- CHANGELOG section ---"; sed 's/^/ /' /tmp/cut-release-section.md + + run "insert_changelog /tmp/cut-release-section.md" + run "git add CHANGELOG.md" + run "git commit -m 'chore(release): $tag'" + run "git tag '$tag'" + run "git push origin main '$tag'" + run "gh release create '$tag' --title '$tag' --notes-file /tmp/cut-release-section.md" + + step "advance the floating major alias v${ver%%.*} -> $tag" + run "git tag -f 'v${ver%%.*}' '$tag^{}'" + run "git push -f origin 'refs/tags/v${ver%%.*}'" + + echo "Done: $tag released; v${ver%%.*} alias updated." +} + +# Only run when executed, not when sourced (so the helpers are unit-testable). +if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then main; fi