diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 74f09dce..0d4d6d3c 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"id":"forge-1bo","title":"Restore post-merge workflow runtime dependencies","description":".github/workflows/post-merge.yml now correctly fires on master after PR #5 / merge 662b43ae, proving forge-2fq's trigger fix worked. However, the workflow fails in Classify Merge because it depends on .claude/scripts/classify-merge-pr.sh, which is not present in the CI checkout after .claude/ was untracked by commit 931d4c89.\n\nThe workflow was structurally broken but hidden while the trigger was dormant.\n\nRun: https://github.com/0xElCapitan/forge/actions/runs/26529891069\n\nDecide whether to selectively re-track workflow-critical scripts, refactor post-merge.yml to remove .claude dependencies, or adopt a proper Loa/submodule/script-install strategy.","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-27T18:19:04.088423400Z","created_by":"unknown","updated_at":"2026-05-27T18:19:04.088423400Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["ci","cycle-002-sprint-01-followup","follow-up"]} {"id":"forge-1ce","title":"Add PR-level CI for FORGE","description":"FORGE currently has no PR-level CI workflow, so PR #4 (cycle-002 sprint-01 ir-0.2.0-bundle) could not receive pre-merge checks. Sprint 01 used local verification plus review/audit as the merge gate.\n\nAdd a PR workflow that runs at minimum:\n- npm run test:unit\n- npm run test:all\n\nTriggers: pull_request / push as appropriate for the repo's PR-flow conventions.\n\nContext: surfaced 2026-05-27 during cycle-002 sprint-01 ir-0.2.0-bundle release sequence. Local 777/792 pass + Bridgebuilder review APPROVED + Paranoid Cypherpunk Auditor PASS were the substitute gate; this is acceptable for a contract-surface change but should not be the long-term default.","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-27T17:54:59.147203200Z","created_by":"unknown","updated_at":"2026-05-27T17:54:59.147203200Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["ci","cycle-002-sprint-01-followup","follow-up"]} {"id":"forge-2fq","title":"Fix dormant post-merge workflow branch trigger","description":".github/workflows/post-merge.yml is configured for 'branches: [main]', but the repo default branch is 'master'. As a result, the post-merge workflow did not run after Sprint 01 merge commit 16451951f48a25d0741fd27525c9d9066abf8907 (PR #4, 'cycle-002 sprint-01 ir-0.2.0-bundle').\n\nUpdate the workflow trigger to match 'master' or otherwise align the repo branch strategy.\n\nSurfaced 2026-05-27 during the cycle-002 sprint-01 release sequence. Pre-existing repo defect, not introduced by Sprint 01. Every recent run in 'gh run list' is 'Dependabot Updates'; 'Post-Merge Pipeline' has been dormant for an unknown duration prior to this discovery.\n\nSuggested fix: change line 5 of .github/workflows/post-merge.yml from 'branches: [main]' to 'branches: [master, main]' (covers both names), or settle on one canonical default-branch name and align everything accordingly.\n\nVerification after fix: push a no-op commit to master (or merge a trivial PR) and confirm 'gh run list --workflow=\"Post-Merge Pipeline\"' shows a new run.","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-27T17:55:10.416523700Z","created_by":"unknown","updated_at":"2026-05-27T17:55:10.416523700Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["ci","cycle-002-sprint-01-followup","follow-up"]} {"id":"forge-ewa","title":"Reconcile FORGE construct snapshot fixtures with current emitter output","description":"Sprint 01 dry-run found fixtures/forge-snapshots-{tremor,corona,breath}.json are stale/orphan documentation fixtures. Regeneration introduces unrelated serializeProfile field renames and usefulness_score normalization drift in addition to expected IR/Lane 1 fields. Decide whether to modernize, retire, or reconnect these fixtures to tests in a dedicated fixture hygiene task.\n\nContext:\n- Sprint cycle-002-sprint-01-ir-0.2.0-bundle deferred FR-7 / T-E1.\n- Operator authorization 2026-05-27: defer rather than bundle the broader drift into the IR 0.2.0 commit set.\n- No current test consumes these fixtures.\n- Drift categories observed: serializeProfile field renames (cadence.median_gap_ms/cv -> median_ms/jitter_coefficient; noise.spike_ratio -> spike_rate; density.stream_count -> sensor_count); usefulness_score going from 0.0594 to null (analyze() invocation signature change); plus expected IR 0.2.0 + Lane 1 additions.\n\nOptions to consider in the dedicated task:\n1. Modernize: regenerate fully with current emitter output, accept the broader diff.\n2. Retire: delete fixtures; remove README references; replace with generated test fixtures if any consumer surfaces.\n3. Reconnect: wire fixtures back into a snapshot test so drift is detected automatically.","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-27T16:40:44.748530300Z","created_by":"unknown","updated_at":"2026-05-27T16:40:44.748530300Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["cycle-002-sprint-01-deferred","fixture-hygiene","follow-up"]} diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index 61d5ea6a..6631c00f 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -81,8 +81,11 @@ jobs: # readability only — do NOT re-parse $GITHUB_OUTPUT (it's a # producer-write, runner-read contract; reading it back inverts # GitHub's intent and breaks if the wrapper ever uses heredoc syntax). - chmod +x .claude/scripts/classify-merge-pr.sh - RESULT=$(.claude/scripts/classify-merge-pr.sh --merge-sha "$MERGE_SHA") + # forge-1bo migration (cycle-002): runtime scripts moved from + # untracked `.claude/scripts/` into tracked `scripts/ci/` so the + # workflow survives `.claude/` being gitignored (commit 931d4c89). + chmod +x scripts/ci/classify-merge-pr.sh scripts/ci/classify-pr-type.sh + RESULT=$(scripts/ci/classify-merge-pr.sh --merge-sha "$MERGE_SHA") echo "Classifier output:" echo "$RESULT" @@ -116,8 +119,14 @@ jobs: - name: Compute semver id: semver run: | - chmod +x .claude/scripts/semver-bump.sh .claude/scripts/bootstrap.sh - RESULT=$(.claude/scripts/semver-bump.sh 2>/dev/null || echo '{}') + # forge-1bo migration (cycle-002): runtime scripts moved from + # untracked `.claude/scripts/` into tracked `scripts/ci/`. + # The migrated semver-bump.sh additionally honors `[skip release]`, + # `[skip ci-release]`, and `[no-bump]` markers in commits between + # the latest tag and HEAD — if found, it emits `{}` and exits 0, + # causing the workflow to skip the "Create tag" step below. + chmod +x scripts/ci/semver-bump.sh scripts/ci/bootstrap.sh + RESULT=$(scripts/ci/semver-bump.sh 2>/dev/null || echo '{}') NEXT=$(echo "$RESULT" | jq -r '.next // empty') BUMP=$(echo "$RESULT" | jq -r '.bump // empty') @@ -203,6 +212,19 @@ jobs: PM_PR_NUMBER: ${{ needs.classify.outputs.pr_number }} PM_MERGE_SHA: ${{ github.sha }} run: | + # KNOWN LIMITATION (forge-1bo scope): the cycle-PR shell-only path + # below still calls `.claude/scripts/*`, which are not tracked in + # CI checkout. This path was DEFERRED from the forge-1bo migration + # (chore-PR / simple-release path was the bounded scope). For + # cycle PRs WITHOUT secrets.ANTHROPIC_API_KEY set, this step will + # fail with `chmod: cannot access '.claude/scripts/...'`. The + # Claude Code Action path (runs inside the Anthropic container + # with Loa loaded) is the supported cycle-PR route. + # If a future cycle PR needs shell-only fallback, a follow-up + # bead must migrate post-merge-orchestrator.sh + transitive deps + # (release-notes-gen.sh, classify-pr-type.sh, ground-truth-gen.sh, + # classify-commit-zone.sh, lore-promote.sh, path-lib.sh) into + # scripts/ci/. echo "Running shell-only pipeline (no ANTHROPIC_API_KEY)" chmod +x .claude/scripts/post-merge-orchestrator.sh .claude/scripts/semver-bump.sh .claude/scripts/release-notes-gen.sh .claude/scripts/bootstrap.sh .claude/scripts/post-merge-orchestrator.sh \ diff --git a/scripts/ci/bootstrap.sh b/scripts/ci/bootstrap.sh new file mode 100755 index 00000000..cdaf444b --- /dev/null +++ b/scripts/ci/bootstrap.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# bootstrap.sh - Workspace context initialization +# Version: 1.0.0 +# +# Sourced by all Loa scripts before any other operations. +# Establishes PROJECT_ROOT and sources path-lib.sh. +# +# Usage: +# source "$SCRIPT_DIR/bootstrap.sh" +# +# Environment Variables Set: +# PROJECT_ROOT - Canonical workspace root path +# CONFIG_FILE - Path to .loa.config.yaml +# +# Detection Priority: +# 1. Existing PROJECT_ROOT (inheritance from parent script) +# 2. git rev-parse --show-toplevel (git repo root) +# 3. Walk up to find .claude/ directory +# 4. Walk up to find .loa.config.yaml file +# 5. Fallback to current directory (with warning) + +set -euo pipefail + +# ============================================================================= +# PROJECT_ROOT Detection +# ============================================================================= + +_detect_project_root() { + # Strategy 1: Git repository root (handles worktrees and submodules) + if command -v git &>/dev/null; then + local git_root + # --show-toplevel works with worktrees + # For submodules, it returns the submodule root, which is correct + git_root=$(git rev-parse --show-toplevel 2>/dev/null) || true + if [[ -n "$git_root" && -d "$git_root" ]]; then + echo "$git_root" + return 0 + fi + fi + + # Strategy 2: Walk up looking for .claude/ directory + local dir="$PWD" + while [[ "$dir" != "/" ]]; do + if [[ -d "$dir/.claude" ]]; then + echo "$dir" + return 0 + fi + dir=$(dirname "$dir") + done + + # Strategy 3: Walk up looking for .loa.config.yaml + dir="$PWD" + while [[ "$dir" != "/" ]]; do + if [[ -f "$dir/.loa.config.yaml" ]]; then + echo "$dir" + return 0 + fi + dir=$(dirname "$dir") + done + + # Fallback: current directory (may be wrong) + echo "WARNING: Could not detect PROJECT_ROOT, using current directory" >&2 + echo "$PWD" + return 1 +} + +# Only initialize if not already set (allows inheritance from parent script) +if [[ -z "${PROJECT_ROOT:-}" ]]; then + PROJECT_ROOT=$(_detect_project_root) +fi + +# Canonicalize PROJECT_ROOT to absolute path +# Use realpath for consistency across all scripts +if command -v realpath &>/dev/null; then + PROJECT_ROOT=$(realpath "$PROJECT_ROOT") +else + # Fallback for systems without realpath (rare) + PROJECT_ROOT=$(cd "$PROJECT_ROOT" && pwd) +fi +export PROJECT_ROOT + +# Config file location +CONFIG_FILE="${PROJECT_ROOT}/.loa.config.yaml" +export CONFIG_FILE + +# ============================================================================= +# Source Path Library +# ============================================================================= + +# Determine script directory (may differ from PROJECT_ROOT/.claude/scripts if symlinked) +_BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source path-lib.sh if it exists +if [[ -f "$_BOOTSTRAP_DIR/path-lib.sh" ]]; then + source "$_BOOTSTRAP_DIR/path-lib.sh" +fi + +# Cleanup internal variable +unset _BOOTSTRAP_DIR diff --git a/scripts/ci/classify-merge-pr.sh b/scripts/ci/classify-merge-pr.sh new file mode 100755 index 00000000..082a0060 --- /dev/null +++ b/scripts/ci/classify-merge-pr.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# ============================================================================= +# classify-merge-pr.sh — workflow-level merge classifier (Issue #668) +# ============================================================================= +# Wraps classify-pr-type.sh with merge-context handling so the post-merge +# pipeline classifies cycle PRs correctly even when `gh pr view` returns +# empty title/labels in the GitHub Actions runner. The merge commit subject +# is in-tree state and never empty by the time post-merge runs, so it is the +# PRIMARY signal. `gh pr view` labels are SECONDARY enrichment — when gh +# fails, the failure is surfaced loudly to stderr (no silent swallow) and +# the wrapper falls through to subject-only classification. +# +# Usage: +# classify-merge-pr.sh --merge-sha [--pr-number ] +# classify-merge-pr.sh --merge-msg "" [--pr-number ] +# +# Output (stdout): +# pr_type= +# pr_number= +# +# When $GITHUB_OUTPUT is set, the same key=value lines are appended there. +# +# Exit codes: +# 0 — classified successfully +# 2 — bad arguments +# ============================================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +MERGE_SHA="" +MERGE_MSG="" +PR_NUMBER="" +MERGE_MSG_SET=0 + +usage() { + cat < [--pr-number ] + classify-merge-pr.sh --merge-msg "" [--pr-number ] + +Classifies a merged PR by inspecting the merge commit subject (PRIMARY) and +optionally enriching with gh pr view labels (SECONDARY). Output: + pr_type= + pr_number= +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --merge-sha) + [[ $# -ge 2 ]] || { echo "ERROR: --merge-sha requires a value" >&2; exit 2; } + MERGE_SHA="$2"; shift 2 ;; + --merge-msg) + [[ $# -ge 2 ]] || { echo "ERROR: --merge-msg requires a value" >&2; exit 2; } + MERGE_MSG="$2"; MERGE_MSG_SET=1; shift 2 ;; + --pr-number) + [[ $# -ge 2 ]] || { echo "ERROR: --pr-number requires a value" >&2; exit 2; } + PR_NUMBER="$2"; shift 2 ;; + -h|--help) + usage; exit 0 ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 ;; + esac +done + +# Must have either --merge-sha or --merge-msg (an empty --merge-msg is valid; +# the classifier returns "other" for empty subjects — graceful path). +if [[ -z "$MERGE_SHA" && "$MERGE_MSG_SET" -eq 0 ]]; then + echo "ERROR: one of --merge-sha or --merge-msg is required" >&2 + usage >&2 + exit 2 +fi + +# Resolve subject from SHA if --merge-sha was passed and --merge-msg was not +if [[ -z "$MERGE_MSG" && -n "$MERGE_SHA" ]]; then + if MERGE_MSG=$(git log -1 --format='%s' "$MERGE_SHA" 2>/dev/null); then + : # got it + else + echo "[classify-merge-pr] WARN: failed to resolve subject from sha=$MERGE_SHA" >&2 + MERGE_MSG="" + fi +fi + +# Extract PR number from merge message if not provided +if [[ -z "$PR_NUMBER" && -n "$MERGE_MSG" ]]; then + # Match (#NNN) or trailing #NNN + PR_NUMBER=$(echo "$MERGE_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true) +fi + +# Source the shared rules engine +CLASSIFIER="${SCRIPT_DIR}/classify-pr-type.sh" +if [[ ! -f "$CLASSIFIER" ]]; then + echo "[classify-merge-pr] ERROR: classify-pr-type.sh not found at $CLASSIFIER" >&2 + exit 2 +fi +# shellcheck source=classify-pr-type.sh +source "$CLASSIFIER" + +# PRIMARY: classify by merge subject alone (in-tree state, never empty) +PR_TYPE_PRIMARY=$(classify_pr_type "$MERGE_MSG" "") + +# SECONDARY: try gh pr view for label enrichment. If it fails, log loud +# but continue with PRIMARY result. Capture stderr to a temp file so the +# real failure surfaces in the workflow log. +LABELS="" +GH_FAILED=0 +if [[ -n "$PR_NUMBER" ]] && command -v gh >/dev/null 2>&1; then + gh_stderr=$(mktemp) + if gh_json=$(gh pr view "$PR_NUMBER" --json title,labels 2>"$gh_stderr"); then + # Successful gh call — extract labels (jq '.labels[].name') + LABELS=$(echo "$gh_json" | jq -r '[.labels[]?.name] | join(",")' 2>/dev/null || echo "") + else + GH_FAILED=1 + # Surface the failure loudly. Caller can grep for [classify-merge-pr] in logs. + echo "[classify-merge-pr] WARN: gh pr view failed for PR #${PR_NUMBER}; falling through to subject-only classification" >&2 + if [[ -s "$gh_stderr" ]]; then + echo "[classify-merge-pr] gh stderr: $(cat "$gh_stderr")" >&2 + fi + fi + rm -f "$gh_stderr" +elif [[ -n "$PR_NUMBER" ]]; then + echo "[classify-merge-pr] WARN: gh CLI not available; skipping label enrichment for PR #${PR_NUMBER}" >&2 +fi + +# If enrichment succeeded AND labels imply cycle, override PRIMARY +if [[ -n "$LABELS" ]] && echo "$LABELS" | grep -qi "cycle"; then + PR_TYPE="cycle" +else + PR_TYPE="$PR_TYPE_PRIMARY" +fi + +# Emit result +echo "pr_type=${PR_TYPE}" +echo "pr_number=${PR_NUMBER}" + +# Also append to $GITHUB_OUTPUT for GitHub Actions consumers +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "pr_type=${PR_TYPE}" + echo "pr_number=${PR_NUMBER}" + } >>"$GITHUB_OUTPUT" +fi + +exit 0 diff --git a/scripts/ci/classify-pr-type.sh b/scripts/ci/classify-pr-type.sh new file mode 100755 index 00000000..25a3b0d0 --- /dev/null +++ b/scripts/ci/classify-pr-type.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ============================================================================= +# classify-pr-type.sh — shared classifier for post-merge pipeline routing +# ============================================================================= +# Part of Issue #550 fix. Previously, two separate sites embedded the same +# classifier logic and drifted: +# - .github/workflows/post-merge.yml (narrow regex, missed feat(models):…) +# - .claude/scripts/post-merge-orchestrator.sh (added `feat:` catch-all, +# false-positive on any feature PR) +# +# This helper is the single source of truth. Both sites now source it. +# +# Usage (sourced): +# source .claude/scripts/classify-pr-type.sh +# pr_type=$(classify_pr_type "$TITLE" "$LABELS") +# +# Usage (standalone, for scripts that can't source): +# .claude/scripts/classify-pr-type.sh --title "" --labels "<labels>" +# +# Output: one of "cycle", "bugfix", "other" (on stdout). +# ============================================================================= + +# classify_pr_type — classify a PR by title and labels +# +# Returns one of: +# cycle — PR represents a full cycle (CHANGELOG, GT, RTFM, Release run) +# bugfix — PR is a bugfix (tag + simple release only) +# other — PR doesn't match either (tag only) +# +# Rules (in precedence order): +# 1. Label contains "cycle" (case-insensitive) → cycle +# 2. Title matches cycle-NNN anywhere → cycle +# 3. Title starts with one of the cycle prefixes +# (Run Mode, Sprint Plan, feat(sprint, feat(cycle) → cycle +# 4. Title starts with "fix" → bugfix +# 5. Otherwise → other +# +# NOTE: `^feat:` (bare) is deliberately NOT treated as cycle — that was a +# pre-existing false-positive in post-merge-orchestrator.sh that classified +# every feat: PR (even small features) as a full cycle. Use `feat(cycle-NNN):` +# or `feat(sprint-NNN):` for cycle-scoped PRs. +classify_pr_type() { + local title="${1:-}" + local labels="${2:-}" + + if echo "$labels" | grep -qi "cycle"; then + echo "cycle" + return 0 + fi + + if echo "$title" | grep -qE '\bcycle-[0-9]+\b'; then + echo "cycle" + return 0 + fi + + if echo "$title" | grep -qE "^(Run Mode|Sprint Plan|feat\(sprint|feat\(cycle)"; then + echo "cycle" + return 0 + fi + + if echo "$title" | grep -qE "^fix"; then + echo "bugfix" + return 0 + fi + + echo "other" +} + +# When executed directly (not sourced), parse flags and invoke +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + TITLE="" + LABELS="" + while [[ $# -gt 0 ]]; do + case "$1" in + --title) + if [[ $# -lt 2 ]]; then echo "ERROR: --title requires a value" >&2; exit 2; fi + TITLE="$2"; shift 2 ;; + --labels) + if [[ $# -lt 2 ]]; then echo "ERROR: --labels requires a value" >&2; exit 2; fi + LABELS="$2"; shift 2 ;; + -h|--help) + echo "Usage: classify-pr-type.sh --title <title> [--labels <labels>]" + exit 0 ;; + *) + echo "Unknown arg: $1" >&2; exit 2 ;; + esac + done + classify_pr_type "$TITLE" "$LABELS" +fi diff --git a/scripts/ci/semver-bump.sh b/scripts/ci/semver-bump.sh new file mode 100755 index 00000000..c9b04178 --- /dev/null +++ b/scripts/ci/semver-bump.sh @@ -0,0 +1,425 @@ +#!/usr/bin/env bash +# semver-bump.sh - Conventional commit semver parser +# Version: 1.0.0 +# +# Reads git tag history and commit messages to compute the next +# semantic version based on conventional commit prefixes. +# +# Usage: +# .claude/scripts/semver-bump.sh [--from-tag | --from-changelog] +# +# Output: JSON to stdout +# {"current": "1.35.1", "next": "1.36.0", "bump": "minor", "commits": [...]} +# +# Exit Codes: +# 0 - Success +# 1 - No commits since last tag +# 2 - No version source found + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/bootstrap.sh" + +# ============================================================================= +# Bump Priority Map +# ============================================================================= + +# Conventional commit type → bump level +# major > minor > patch +declare -A BUMP_MAP=( + ["feat"]=minor + ["fix"]=patch + ["perf"]=patch + ["refactor"]=patch + ["chore"]=patch + ["docs"]=patch + ["test"]=patch + ["ci"]=patch + ["style"]=patch + ["build"]=patch +) + +# Numeric priority for comparison +declare -A BUMP_PRIORITY=( + ["patch"]=1 + ["minor"]=2 + ["major"]=3 +) + +# ============================================================================= +# Version Utilities +# ============================================================================= + +# Get current version from the latest git tag matching either: +# - vX.Y.Z (release) +# - vX.Y.Z-PRE.N (prerelease, where PRE ∈ {alpha, beta, rc}) +# +# Both shapes are returned to the caller; bump_version() handles the kind +# difference. Pre-1.0 projects that ship through a prerelease cadence (e.g. +# v2.0.0-alpha.7) need this to compute "next version" correctly — without +# the prerelease branch, the strict vX.Y.Z glob silently misses every +# alpha/beta/rc tag and the post-merge orchestrator skips tag/CHANGELOG/ +# release entirely. +get_version_from_tag() { + local tag + # `tag -l` accepts multiple patterns; combine release + prerelease shapes, + # then filter by precise regex (the glob is permissive — matches strings + # like "v1.2.3-foo" too). + tag=$(git -C "$PROJECT_ROOT" tag -l 'v[0-9]*.[0-9]*.[0-9]*' 'v[0-9]*.[0-9]*.[0-9]*-*' \ + --sort=-v:refname 2>/dev/null \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$' \ + | head -1) + if [[ -n "$tag" ]]; then + echo "${tag#v}" + return 0 + fi + return 1 +} + +# Get current version from CHANGELOG.md header +get_version_from_changelog() { + local changelog="${PROJECT_ROOT}/CHANGELOG.md" + if [[ -f "$changelog" ]]; then + local version + version=$(grep -o '## \[[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\]' "$changelog" | head -1 | sed 's/## \[//;s/\]//') + if [[ -n "$version" ]]; then + echo "$version" + return 0 + fi + fi + return 1 +} + +# Bump a version string by type. Handles two shapes: +# +# 1. Release X.Y.Z → bump per `bump` arg (major/minor/patch) +# 2. Prerelease X.Y.Z-PRE.N → increment N (PRE ∈ {alpha, beta, rc}) +# +# Prerelease bumping is type-agnostic by design: while a project is on a +# prerelease cadence (e.g. 2.0.0-alpha.N), conventional-commit signal +# (feat/fix/etc.) does not warrant a major/minor/patch flip — the project +# is still pre-1.0-of-this-major. Promotion out of prerelease (alpha → beta, +# rc → release) is operator-driven and out of scope for this bump path. +# +# Validate version format (M-05) — accept either release or prerelease. +bump_version() { + local current="$1" bump="$2" + local prerelease_re='^([0-9]+)\.([0-9]+)\.([0-9]+)-(alpha|beta|rc)\.([0-9]+)$' + local release_re='^([0-9]+)\.([0-9]+)\.([0-9]+)$' + + if [[ "$current" =~ $prerelease_re ]]; then + local major="${BASH_REMATCH[1]}" + local minor="${BASH_REMATCH[2]}" + local patch="${BASH_REMATCH[3]}" + local pre_kind="${BASH_REMATCH[4]}" + local pre_num="${BASH_REMATCH[5]}" + echo "${major}.${minor}.${patch}-${pre_kind}.$((pre_num + 1))" + return 0 + fi + + if [[ "$current" =~ $release_re ]]; then + local major="${BASH_REMATCH[1]}" + local minor="${BASH_REMATCH[2]}" + local patch="${BASH_REMATCH[3]}" + case "$bump" in + major) echo "$((major + 1)).0.0" ;; + minor) echo "${major}.$((minor + 1)).0" ;; + patch) echo "${major}.${minor}.$((patch + 1))" ;; + *) echo "ERROR: Unknown bump type: $bump" >&2; return 1 ;; + esac + return 0 + fi + + echo "ERROR: Invalid version format: $current" >&2 + return 1 +} + +# ============================================================================= +# Commit Parsing +# ============================================================================= + +# Parse commits since a ref and determine bump type +# Outputs JSON array of commits to stderr, returns bump type on stdout +parse_commits() { + local since_ref="$1" + local commits_json="[]" + local highest_bump="patch" + local highest_priority=0 + local has_breaking=false + + # Check for BREAKING CHANGE in commit bodies + if git -C "$PROJECT_ROOT" log "${since_ref}..HEAD" --format='%B' 2>/dev/null | grep -q 'BREAKING CHANGE:'; then + has_breaking=true + fi + + # Check for ! suffix in commit subjects (e.g., feat!: or feat(scope)!:) + if git -C "$PROJECT_ROOT" log "${since_ref}..HEAD" --format='%s' 2>/dev/null | grep -qE '^[a-z]+(\([^)]*\))?!:'; then + has_breaking=true + fi + + if [[ "$has_breaking" == "true" ]]; then + highest_bump="major" + highest_priority=3 + fi + + # Parse each commit + while IFS= read -r line; do + [[ -z "$line" ]] && continue + + local hash="${line%% *}" + local subject="${line#* }" + + # Extract conventional commit parts + local type="" scope="" msg="$subject" + local cc_regex='^([a-z]+)(\([^)]*\))?(!)?\: (.*)$' + if [[ "$subject" =~ $cc_regex ]]; then + type="${BASH_REMATCH[1]}" + scope="${BASH_REMATCH[2]}" + scope="${scope#(}" + scope="${scope%)}" + msg="${BASH_REMATCH[4]}" + fi + + # Determine bump for this commit type + local commit_bump="patch" + if [[ -n "$type" && -n "${BUMP_MAP[$type]:-}" ]]; then + commit_bump="${BUMP_MAP[$type]}" + fi + + # Track highest bump (if not already major from breaking change) + local priority="${BUMP_PRIORITY[$commit_bump]:-1}" + if [[ "$priority" -gt "$highest_priority" && "$has_breaking" != "true" ]]; then + highest_priority=$priority + highest_bump="$commit_bump" + fi + + # Build commit JSON entry + local commit_entry + commit_entry=$(jq -n \ + --arg hash "$hash" \ + --arg type "${type:-unknown}" \ + --arg scope "${scope:-}" \ + --arg subject "$msg" \ + '{hash: $hash, type: $type, scope: $scope, subject: $subject}') + + commits_json=$(echo "$commits_json" | jq --argjson entry "$commit_entry" '. + [$entry]') + + done < <(git -C "$PROJECT_ROOT" log "${since_ref}..HEAD" --format='%h %s' 2>/dev/null) + + # Output commits JSON to fd 3 + echo "$commits_json" >&3 + # Output bump type to stdout + echo "$highest_bump" +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + local source_mode="auto" + local downstream=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --from-tag) source_mode="tag"; shift ;; + --from-changelog) source_mode="changelog"; shift ;; + --downstream) downstream=true; shift ;; + --help|-h) + echo "Usage: semver-bump.sh [--from-tag | --from-changelog] [--downstream]" + echo " Computes next semver from conventional commits." + echo " Output: JSON with current, next, bump, commits" + echo "" + echo "Options:" + echo " --downstream Filter out non-app commits (system-only, state-only, mixed-internal)" + exit 0 + ;; + *) echo "ERROR: Unknown argument: $1" >&2; exit 2 ;; + esac + done + + # Determine current version + local current="" + local tag_ref="" + + case "$source_mode" in + tag) + current=$(get_version_from_tag) || { echo "ERROR: No version tags found" >&2; exit 2; } + tag_ref="v${current}" + ;; + changelog) + current=$(get_version_from_changelog) || { echo "ERROR: No version in CHANGELOG.md" >&2; exit 2; } + # Try to find matching tag + if git -C "$PROJECT_ROOT" tag -l "v${current}" | grep -q "v${current}"; then + tag_ref="v${current}" + else + echo "ERROR: No tag matching v${current} found" >&2 + exit 2 + fi + ;; + auto) + if current=$(get_version_from_tag); then + tag_ref="v${current}" + elif current=$(get_version_from_changelog); then + if git -C "$PROJECT_ROOT" tag -l "v${current}" | grep -q "v${current}"; then + tag_ref="v${current}" + else + echo "ERROR: CHANGELOG version v${current} has no matching tag" >&2 + exit 2 + fi + else + echo "ERROR: No version source found (no tags, no CHANGELOG)" >&2 + exit 2 + fi + ;; + esac + + # Check for commits since tag + local commit_count + commit_count=$(git -C "$PROJECT_ROOT" rev-list "${tag_ref}..HEAD" --count 2>/dev/null || echo "0") + if [[ "$commit_count" -eq 0 ]]; then + echo "ERROR: No commits since ${tag_ref}" >&2 + exit 1 + fi + + # --------------------------------------------------------------------------- + # Skip-release marker check (cycle-002 forge-1bo migration) + # --------------------------------------------------------------------------- + # Scan commits between $tag_ref and HEAD for opt-out markers in subject or + # body. If found, emit empty JSON and exit 0 — the post-merge workflow's + # `simple-release` job interprets empty/missing `.next` as "skip tag", so + # no software tag is created on this merge. + # + # Recognized markers (case-sensitive, anywhere in commit subject or body): + # [skip release] - generic opt-out + # [skip ci-release] - explicit CI-repair opt-out + # [no-bump] - alternative form + # + # Use cases: + # - CI infrastructure repair PRs (workflows, scripts) that should not + # trigger a software version bump. + # - Documentation-only changes the operator wants tag-suppressed. + # - Reverts or fix-ups where automatic tagging would create noise. + # + # The marker MUST appear in at least one commit between the latest tag and + # HEAD. Because the post-merge workflow checks out the merge commit and + # tag_ref is the latest semver tag, the entire PR commit range is in scope. + # Including the merge commit subject itself — if `gh pr merge --merge + # --subject "... [skip release]"` is used, that subject is matched too. + # + # NOTE: process substitution (`< <(...)`) is used instead of `|` because + # this script runs under `set -euo pipefail`. With a pipeline, `grep -q` + # exits immediately on first match, which closes its stdin; the upstream + # `git log` then dies with SIGPIPE (exit 141), and `pipefail` propagates + # that non-zero exit, making the `if` condition evaluate FALSE even though + # the marker was found. Process substitution sidesteps the pipeline so + # only grep's exit code is tested. + if grep -qE '\[skip release\]|\[skip ci-release\]|\[no-bump\]' \ + < <(git -C "$PROJECT_ROOT" log "${tag_ref}..HEAD" --format='%B' 2>/dev/null); then + echo "[semver-bump] skip-release marker found in commits between ${tag_ref} and HEAD — emitting empty JSON to suppress tag" >&2 + echo '{}' + exit 0 + fi + + # Parse commits and determine bump + local commits_json bump + local tmpdir="${TMPDIR:-/tmp}" + local tmpfile_commits tmpfile_bump + tmpfile_commits=$(mktemp "${tmpdir}/semver-commits-XXXXXXXXXX.json") + tmpfile_bump=$(mktemp "${tmpdir}/semver-bump-XXXXXXXXXX.txt") + + # Ensure cleanup on exit or error + trap 'rm -f "$tmpfile_commits" "$tmpfile_bump"' EXIT + + # parse_commits writes commits JSON to fd 3, bump type to stdout + # Redirect fd 3 to tmpfile_commits, stdout to tmpfile_bump + ( parse_commits "$tag_ref" 3>"$tmpfile_commits" ) > "$tmpfile_bump" + + bump=$(cat "$tmpfile_bump" 2>/dev/null || echo "patch") + bump="${bump%$'\n'}" # Trim trailing newline + commits_json=$(cat "$tmpfile_commits" 2>/dev/null || echo "[]") + rm -f "$tmpfile_commits" "$tmpfile_bump" + trap - EXIT + + # Downstream filtering: keep only app-zone commits (cycle-052) + if [[ "$downstream" == "true" ]]; then + # Source classify-commit-zone.sh for zone classification + local classify_script="${SCRIPT_DIR}/classify-commit-zone.sh" + if [[ -f "$classify_script" ]]; then + source "$classify_script" + + local filtered_json="[]" + local highest_app_bump="patch" + local highest_app_priority=0 + local app_breaking=false + local commit_count_after=0 + + # Iterate each commit, keep only app-zone ones + local total + total=$(echo "$commits_json" | jq 'length') + local i=0 + while [[ "$i" -lt "$total" ]]; do + local hash + hash=$(echo "$commits_json" | jq -r ".[$i].hash") + local zone + zone=$(classify_commit_zone "$hash" 2>/dev/null) || zone="app" + + if [[ "$zone" == "app" ]]; then + local entry + entry=$(echo "$commits_json" | jq ".[$i]") + filtered_json=$(echo "$filtered_json" | jq --argjson e "$entry" '. + [$e]') + + # Recalculate bump from filtered commits + local ctype + ctype=$(echo "$commits_json" | jq -r ".[$i].type") + local commit_bump="${BUMP_MAP[$ctype]:-patch}" + local priority="${BUMP_PRIORITY[$commit_bump]:-1}" + + # Check for breaking change marker + local subject + subject=$(echo "$commits_json" | jq -r ".[$i].subject") + if [[ "$subject" == *"BREAKING CHANGE"* ]] || git -C "$PROJECT_ROOT" log -1 --format='%B' "$hash" 2>/dev/null | grep -q 'BREAKING CHANGE:' 2>/dev/null; then + app_breaking=true + fi + + if [[ "$priority" -gt "$highest_app_priority" ]]; then + highest_app_priority=$priority + highest_app_bump="$commit_bump" + fi + commit_count_after=$((commit_count_after + 1)) + fi + i=$((i + 1)) + done + + commits_json="$filtered_json" + + if [[ "$commit_count_after" -eq 0 ]]; then + echo "ERROR: No app-zone commits since ${tag_ref} (all filtered as internal)" >&2 + exit 1 + fi + + # Update bump based on filtered commits + if [[ "$app_breaking" == "true" ]]; then + bump="major" + else + bump="$highest_app_bump" + fi + fi + fi + + # Calculate next version + local next + next=$(bump_version "$current" "$bump") + + # Output result + jq -n \ + --arg current "$current" \ + --arg next "$next" \ + --arg bump "$bump" \ + --argjson commits "$commits_json" \ + '{current: $current, next: $next, bump: $bump, commits: $commits}' +} + +main "$@"