diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 476ba53..766c0fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,8 @@ -name: CI +name: CI Core + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true on: push: @@ -8,8 +12,10 @@ on: jobs: test-pr: - if: github.event_name == 'pull_request' - name: Test (ubuntu-latest) + if: > + github.event_name == 'pull_request' && + !(github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main') + name: PR / Tests (ubuntu) runs-on: ubuntu-latest steps: @@ -32,7 +38,7 @@ jobs: test-merge: if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') - name: Test (${{ matrix.os }}) + name: Push / Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -58,7 +64,7 @@ jobs: run: bun test test/ dev-draft-release: - name: Dev Draft Release + name: Push(dev) / Draft Release if: github.event_name == 'push' && github.ref == 'refs/heads/dev' needs: test-merge runs-on: ubuntu-latest @@ -72,11 +78,19 @@ jobs: fetch-depth: 0 submodules: recursive + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + - name: Compute dev tag - id: tag + id: meta run: | - BASE_VERSION=$(node -p "require('./package.json').version") - DEV_TAG="v${BASE_VERSION}-dev.${{ github.run_number }}" + bun run scripts/release-meta.ts --allow-invalid --github-output "$GITHUB_OUTPUT" + DEV_TAG="${{ steps.meta.outputs.next_version }}-dev.${{ github.run_number }}" echo "dev_tag=${DEV_TAG}" >> "$GITHUB_OUTPUT" - name: Remove previous dev draft releases and tags @@ -122,9 +136,10 @@ jobs: - name: Create draft prerelease uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.tag.outputs.dev_tag }} + tag_name: ${{ steps.meta.outputs.dev_tag }} target_commitish: ${{ github.sha }} - name: Dev Draft ${{ steps.tag.outputs.dev_tag }} - generate_release_notes: true + name: Dev Draft ${{ steps.meta.outputs.dev_tag }} + body: ${{ steps.meta.outputs.release_notes }} + generate_release_notes: false draft: true prerelease: true diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..8248276 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,30 @@ +name: PR Commit Lint + +on: + pull_request: + branches: [main, dev] + +jobs: + lint: + name: PR / Commit Lint + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Lint commits (PR range) + run: | + bun run scripts/release-meta.ts \ + --mode lint \ + --from-ref "${{ github.event.pull_request.base.sha }}" \ + --to "${{ github.event.pull_request.head.sha }}" diff --git a/.github/workflows/dev-main-pr-template.yml b/.github/workflows/dev-main-pr-template.yml index 26321e2..47194d7 100644 --- a/.github/workflows/dev-main-pr-template.yml +++ b/.github/workflows/dev-main-pr-template.yml @@ -1,4 +1,4 @@ -name: Dev->Main PR Autofill +name: Dev->Main PR Metadata on: pull_request: @@ -7,6 +7,7 @@ on: jobs: autofill: + name: PR / Dev->Main Metadata if: github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main' runs-on: ubuntu-latest permissions: @@ -29,8 +30,35 @@ jobs: const pull_number = context.payload.pull_request.number; const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); - const version = pkg.version || "0.0.0"; - const title = `release: v${version} (dev -> main)`; + const pkgVersion = String(pkg.version || "0.0.0"); + + function parseSemver(v) { + const m = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)$/); + if (!m) return [0, 0, 0]; + return [Number(m[1]), Number(m[2]), Number(m[3])]; + } + + function cmp(a, b) { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (pa[0] !== pb[0]) return pa[0] - pb[0]; + if (pa[1] !== pb[1]) return pa[1] - pb[1]; + return pa[2] - pb[2]; + } + + const tagRefs = await github.paginate(github.rest.repos.listTags, { + owner, + repo, + per_page: 100, + }); + const stableTags = tagRefs + .map((t) => t.name) + .filter((t) => /^v\d+\.\d+\.\d+$/.test(t)); + stableTags.sort((a, b) => cmp(a, b)); + const latestTag = stableTags.length ? stableTags[stableTags.length - 1].replace(/^v/, "") : "0.0.0"; + + const version = cmp(pkgVersion, latestTag) >= 0 ? pkgVersion : latestTag; + const title = `release: v${version}`; const commits = await github.paginate(github.rest.pulls.listCommits, { owner, @@ -48,10 +76,21 @@ jobs: `\n${commitsText}\n` ); + const existingBody = context.payload.pull_request.body || ""; + const preserveManual = /[\s\S]*?/m.test(existingBody); + const nextBody = preserveManual + ? existingBody + .replace(/- Version: .*/m, `- Version: v${version}`) + .replace( + /[\s\S]*?/m, + `\n${commitsText}\n` + ) + : body; + await github.rest.pulls.update({ owner, repo, pull_number, title, - body, + body: nextBody, }); diff --git a/.github/workflows/perf-bench.yml b/.github/workflows/perf-bench.yml index aee933d..3ff6f81 100644 --- a/.github/workflows/perf-bench.yml +++ b/.github/workflows/perf-bench.yml @@ -1,4 +1,8 @@ -name: Perf Bench (Non-blocking) +name: Perf Bench + +concurrency: + group: perf-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true on: pull_request: @@ -20,7 +24,7 @@ on: jobs: bench: - name: Run ci-small benchmark + name: Bench / ci-small runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b5d78e..52f34e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,9 @@ name: Release +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: @@ -30,112 +34,27 @@ jobs: - name: Run tests run: bun test test/ - - name: Generate changelog from merged PRs - id: changelog - env: - GH_TOKEN: ${{ github.token }} + - name: Compute release metadata from conventional commits + id: meta run: | - VERSION="${GITHUB_REF_NAME#v}" - TAG="${GITHUB_REF_NAME}" - - # Find previous tag - PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - - if [ -z "$PREV_TAG" ]; then - echo "No previous tag found, using all merged PRs" - PREV_DATE="2000-01-01" - else - PREV_DATE=$(git log -1 --format=%aI "$PREV_TAG" | cut -dT -f1) - fi - - NOW=$(date -u +%Y-%m-%d) - - # Fetch merged PRs between previous tag date and now - PRS=$(gh pr list \ - --state merged \ - --search "merged:${PREV_DATE}..${NOW}" \ - --json number,title,mergedAt \ - --limit 100 \ - --jq '.[] | "\(.number)\t\(.title)"' 2>/dev/null || echo "") - - # Categorize PRs by conventional commit prefix - FIXED="" - ADDED="" - CHANGED="" - DOCS="" - OTHER="" - - while IFS=$'\t' read -r num title; do - [ -z "$num" ] && continue - entry="- ${title} ([#${num}](https://github.com/${{ github.repository }}/pull/${num}))" - - case "$title" in - fix:*|fix\(*) FIXED="${FIXED}${entry}"$'\n' ;; - feat:*|feat\(*) ADDED="${ADDED}${entry}"$'\n' ;; - chore:*|chore\(*|refactor:*|refactor\(*|perf:*|perf\(*) CHANGED="${CHANGED}${entry}"$'\n' ;; - docs:*|docs\(*) DOCS="${DOCS}${entry}"$'\n' ;; - *) OTHER="${OTHER}${entry}"$'\n' ;; - esac - done <<< "$PRS" - - # Build release notes - NOTES="" - - if [ -n "$FIXED" ]; then - NOTES="${NOTES}### Fixed"$'\n\n'"${FIXED}"$'\n' - fi - if [ -n "$ADDED" ]; then - NOTES="${NOTES}### Added"$'\n\n'"${ADDED}"$'\n' - fi - if [ -n "$CHANGED" ]; then - NOTES="${NOTES}### Changed"$'\n\n'"${CHANGED}"$'\n' - fi - if [ -n "$DOCS" ]; then - NOTES="${NOTES}### Documentation"$'\n\n'"${DOCS}"$'\n' - fi - if [ -n "$OTHER" ]; then - NOTES="${NOTES}### Other"$'\n\n'"${OTHER}"$'\n' - fi - - # Fallback: if no PRs found, use auto-generated notes - if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then - echo "HAS_NOTES=false" >> "$GITHUB_OUTPUT" - else - echo "HAS_NOTES=true" >> "$GITHUB_OUTPUT" + bun run scripts/release-meta.ts \ + --current-tag "${GITHUB_REF_NAME}" \ + --to "${GITHUB_SHA}" \ + --github-output "$GITHUB_OUTPUT" - # Build full changelog entry - CHANGELOG_ENTRY="## [${VERSION}] - ${NOW}"$'\n\n'"${NOTES}" - - # Prepend to CHANGELOG.md - if [ -f CHANGELOG.md ]; then - # Insert after the header line(s) - echo "${CHANGELOG_ENTRY}" | cat - CHANGELOG.md > CHANGELOG.tmp - mv CHANGELOG.tmp CHANGELOG.md - else - printf '%s\n\n%s' "# Changelog" "${CHANGELOG_ENTRY}" > CHANGELOG.md - fi - - # Save notes for release body - { - echo "RELEASE_NOTES<> "$GITHUB_OUTPUT" - fi - - - name: Commit CHANGELOG.md - if: steps.changelog.outputs.HAS_NOTES == 'true' + - name: Validate tag matches semantic bump run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - git commit -m "docs: update CHANGELOG.md for ${GITHUB_REF_NAME} [skip ci]" - git push origin HEAD:main + EXPECTED="${{ steps.meta.outputs.next_version }}" + ACTUAL="${GITHUB_REF_NAME}" + if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "Tag/version mismatch: expected ${EXPECTED}, got ${ACTUAL}" + exit 1 + fi - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - body: ${{ steps.changelog.outputs.HAS_NOTES == 'true' && steps.changelog.outputs.RELEASE_NOTES || '' }} - generate_release_notes: ${{ steps.changelog.outputs.HAS_NOTES != 'true' }} + body: ${{ steps.meta.outputs.release_notes }} + generate_release_notes: false draft: false prerelease: ${{ contains(github.ref_name, '-') }} diff --git a/docs/CI_HARDENING_EXECUTION_PLAN.md b/docs/CI_HARDENING_EXECUTION_PLAN.md new file mode 100644 index 0000000..f0aa41c --- /dev/null +++ b/docs/CI_HARDENING_EXECUTION_PLAN.md @@ -0,0 +1,38 @@ +# CI Hardening Execution State + +Last updated: 2026-02-27 + +## Scope +Implements the requested improvements except design-contract re-enable (explicitly deferred). + +## Completed in This Change +- Added conventional-commit driven release metadata engine: + - `scripts/release-meta.ts` +- Added commit lint workflow: + - `.github/workflows/commitlint.yml` +- Updated `CI` dev draft release to derive semver + notes from commits: + - `.github/workflows/ci.yml` +- Updated stable release workflow to: + - validate pushed tag against computed semver + - generate release notes from exact conventional commits + - stop mutating `main` during release + - `.github/workflows/release.yml` +- Updated dev->main PR title format: + - removed `dev -> main` suffix from title + - preserve manual PR body sections while refreshing autogenerated block + - `.github/workflows/dev-main-pr-template.yml` +- Added concurrency controls: + - `ci.yml`, `perf-bench.yml`, `release.yml` + +## North Star +No bad release should be publishable without: +1. passing required checks, +2. semver consistency, +3. conventional commit compliance, +4. deterministic release notes from the actual commit set. + +## Operating Rules +- Merge strategy for protected branches should preserve conventional commit subjects + (squash merge title must be conventional). +- Do not bypass commit lint for release-bearing branches. +- Any temporary workflow disable must include expiry date and tracking issue. diff --git a/docs/WORKFLOW_AUTOMATION.md b/docs/WORKFLOW_AUTOMATION.md new file mode 100644 index 0000000..2bebdb4 --- /dev/null +++ b/docs/WORKFLOW_AUTOMATION.md @@ -0,0 +1,90 @@ +# Workflow Automation: Current State and North Star + +Last updated: 2026-02-27 + +## Goals +- Keep `dev` as the stabilization branch. +- Automatically produce an **unreleased draft prerelease** from `dev` after tests pass. +- Promote `dev -> main` with standardized release PR metadata. +- Prevent bad releases by gating release on cross-platform tests and security checks. + +## Current Workflow Map + +### 1) CI (`.github/workflows/ci.yml`) +- Triggers: + - `push` on `main`, `dev`, `feature/**` + - `pull_request` on `main`, `dev` +- Jobs: + - `test-pr`: PRs run fast Linux-only tests (`bun test test/`). + - `test-merge`: pushes to `main`/`dev` run full matrix (`ubuntu`, `macos`, `windows`). + - `dev-draft-release`: runs **only on push to `dev`**, after `test-merge` succeeds. +- Dev draft release behavior: + - Creates tag `v-dev.` + - Deletes previous draft prerelease/tag matching `-dev.*` + - Creates new GitHub draft prerelease with generated notes. + +### 2) Dev->Main PR Autofill (`.github/workflows/dev-main-pr-template.yml`) +- Trigger: `pull_request` events targeting `main`. +- Condition: applies only when `head=dev` and `base=main`. +- Actions: + - Sets PR title to `release: v` + - Fills PR body from `.github/PULL_REQUEST_TEMPLATE/dev-to-main.md` + - Injects auto-generated commit list. + +### 3) Perf Bench (`.github/workflows/perf-bench.yml`) +- Triggers on relevant code/path changes (PR and push). +- Runs QMD benchmark + repeat runs. +- Produces scorecard markdown. +- Publishes: + - GitHub job summary + - Sticky PR comment (updated in place) + - Artifacts (`ci-small.json`, `repeat-summary.json`, `scorecard.md`) +- Non-blocking regression compare currently. + +### 4) Release (`.github/workflows/release.yml`) +- Trigger: push tag matching `v*.*.*` +- Runs tests, generates changelog notes, creates GitHub Release. +- Final release is published when semver tag is pushed (e.g. `v0.4.0`). + +### 5) Secret Scan (`.github/workflows/secret-scan.yml`) +- Runs on PR/push for `main`, `dev`, `feature/**`, `staging`. +- Uses `gitleaks` + `detect-secrets`. + +### 6) Install Test (`.github/workflows/install-test.yml`) +- Runs on push to `main`, tags, or manual dispatch. +- Validates installer/uninstaller and smoke CLI checks on all three OSes. + +### 7) Design Contracts (`.github/workflows/validate-design.yml`) +- Present but currently disabled (`if: ${{ false }}`) pending rule/code alignment. + +## Current Release Flow (As Implemented) + +1. Feature PR -> `dev` +2. Merge to `dev` +3. `CI` full matrix passes on `dev` +4. `CI` creates/updates draft prerelease tag `vX.Y.Z-dev.N` +5. Open PR `dev -> main` (autofilled title/body) +6. Merge `dev -> main` +7. Push final release tag `vX.Y.Z` +8. `Release` workflow publishes stable release + +## What Is Automated vs Manual + +Automated now: +- Dev draft prerelease creation/update after successful `dev` matrix tests. +- Dev->Main PR title/body normalization and commit summary. +- Bench reporting in PR summary/comment. + +Manual now: +- Final semver tag push on `main` (`vX.Y.Z`). +- Deciding when `dev` is release-ready. + +## North Star: Fully Autonomous and Safe Release + +North star definition: +- Every merge to `dev` produces a validated draft candidate. +- Promotion from `dev` to `main` is policy-gated and reproducible. +- Stable release publication is automated only when all release gates are green. +- No single human step can bypass required quality/safety checks. + +Current policy is implemented by the active workflows listed above; no open in-repo workflow backlog is tracked in this document. diff --git a/package.json b/package.json index 8a9e7cf..6dd3e2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smriti", - "version": "0.3.2", + "version": "0.4.0", "description": "Smriti - Unified memory layer across all AI agents", "type": "module", "bin": { @@ -16,7 +16,8 @@ "bench:compare": "bun run scripts/bench-compare.ts --baseline bench/baseline.ci-small.json --current bench/results/ci-small.json --threshold 0.2", "bench:scorecard": "bun run scripts/bench-scorecard.ts --baseline bench/baseline.ci-small.json --profile ci-small --threshold-pct 20", "bench:ingest-hotpaths": "bun run scripts/bench-ingest-hotpaths.ts", - "bench:ingest-pipeline": "bun run scripts/bench-ingest-pipeline.ts --sessions 120 --messages 12" + "bench:ingest-pipeline": "bun run scripts/bench-ingest-pipeline.ts --sessions 120 --messages 12", + "release:meta": "bun run scripts/release-meta.ts" }, "dependencies": { "node-llama-cpp": "^3.0.0", diff --git a/scripts/release-meta.ts b/scripts/release-meta.ts new file mode 100644 index 0000000..3da67fb --- /dev/null +++ b/scripts/release-meta.ts @@ -0,0 +1,236 @@ +#!/usr/bin/env bun + +import { execSync } from "node:child_process"; + +type Commit = { + sha: string; + subject: string; + body: string; + type: string; + scope: string | null; + breaking: boolean; +}; + +type Bump = "none" | "patch" | "minor" | "major"; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function run(cmd: string): string { + return execSync(cmd, { encoding: "utf8" }).trim(); +} + +function isStableTag(tag: string): boolean { + return /^v\d+\.\d+\.\d+$/.test(tag); +} + +function parseSemver(tag: string): [number, number, number] { + const m = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/); + if (!m) return [0, 0, 0]; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function fmtSemver(v: [number, number, number]): string { + return `v${v[0]}.${v[1]}.${v[2]}`; +} + +function bump(base: [number, number, number], level: Bump): [number, number, number] { + const [maj, min, pat] = base; + if (level === "major") return [maj + 1, 0, 0]; + if (level === "minor") return [maj, min + 1, 0]; + if (level === "patch") return [maj, min, pat + 1]; + return [maj, min, pat]; +} + +function maxBump(a: Bump, b: Bump): Bump { + const order: Record = { none: 0, patch: 1, minor: 2, major: 3 }; + return order[a] >= order[b] ? a : b; +} + +function getLatestStableTag(exclude?: string): string | null { + const tags = run("git tag --list") + .split("\n") + .map((t) => t.trim()) + .filter(Boolean) + .filter(isStableTag) + .filter((t) => !exclude || t !== exclude); + if (tags.length === 0) return null; + tags.sort((a, b) => { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (pa[0] !== pb[0]) return pa[0] - pb[0]; + if (pa[1] !== pb[1]) return pa[1] - pb[1]; + return pa[2] - pb[2]; + }); + return tags[tags.length - 1] || null; +} + +function parseCommit(raw: string): Commit | null { + const parts = raw.split("\t"); + if (parts.length < 3) return null; + const sha = parts[0] || ""; + const subject = parts[1] || ""; + const body = parts.slice(2).join("\t"); + const m = subject.match(/^([a-z]+)(?:\(([^)]+)\))?(!)?: (.+)$/); + if (!m) { + return { + sha, + subject, + body, + type: "invalid", + scope: null, + breaking: false, + }; + } + const type = m[1] || "invalid"; + const scope = m[2] || null; + const breaking = Boolean(m[3]) || /BREAKING CHANGE:/i.test(body); + return { sha, subject, body, type, scope, breaking }; +} + +function bumpForCommit(c: Commit): Bump { + if (c.breaking) return "major"; + if (c.type === "feat") return "minor"; + if (c.type === "fix" || c.type === "perf" || c.type === "refactor" || c.type === "revert") return "patch"; + return "none"; +} + +function isConventional(c: Commit): boolean { + if (c.type === "invalid") return false; + const allowed = new Set([ + "feat", + "fix", + "perf", + "refactor", + "docs", + "chore", + "ci", + "test", + "build", + "revert", + "release", + ]); + return allowed.has(c.type); +} + +function getCommits(rangeFrom: string | null, rangeTo: string): Commit[] { + const range = rangeFrom ? `${rangeFrom}..${rangeTo}` : rangeTo; + const raw = run(`git log --no-merges --pretty=format:%H%x09%s%x09%b ${range}`); + if (!raw) return []; + return raw + .split("\n") + .map(parseCommit) + .filter((c): c is Commit => Boolean(c)); +} + +function buildNotes(commits: Commit[]): string { + const valid = commits.filter(isConventional); + if (valid.length === 0) return "### Changed\n\n- No user-facing conventional commits in this range.\n"; + + const groups: Record = { + major: [], + feat: [], + fix: [], + perf: [], + refactor: [], + docs: [], + chore: [], + ci: [], + test: [], + build: [], + revert: [], + release: [], + }; + + for (const c of valid) { + const line = `- ${c.subject} (${c.sha.slice(0, 7)})`; + if (c.breaking) groups.major.push(line); + (groups[c.type] || groups.chore).push(line); + } + + const sections: string[] = []; + if (groups.major.length) sections.push(`### Breaking\n\n${groups.major.join("\n")}`); + if (groups.feat.length) sections.push(`### Added\n\n${groups.feat.join("\n")}`); + if (groups.fix.length || groups.perf.length || groups.refactor.length || groups.revert.length) { + sections.push( + `### Changed\n\n${[...groups.fix, ...groups.perf, ...groups.refactor, ...groups.revert].join("\n")}` + ); + } + if (groups.docs.length) sections.push(`### Documentation\n\n${groups.docs.join("\n")}`); + const ops = [...groups.chore, ...groups.ci, ...groups.test, ...groups.build, ...groups.release]; + if (ops.length) sections.push(`### Maintenance\n\n${ops.join("\n")}`); + return `${sections.join("\n\n")}\n`; +} + +function main() { + const mode = arg("--mode") || "metadata"; + const toRef = arg("--to") || "HEAD"; + const fromRefArg = arg("--from-ref"); + const fromTagArg = arg("--from-tag"); + const currentTag = arg("--current-tag"); + const githubOutput = arg("--github-output"); + const allowInvalid = process.argv.includes("--allow-invalid"); + + const fromTag = fromTagArg || getLatestStableTag(currentTag || undefined); + const rangeFrom = fromRefArg || fromTag; + const commits = getCommits(rangeFrom || null, toRef); + const invalid = commits.filter((c) => !isConventional(c)); + + if (mode === "lint") { + if (invalid.length > 0) { + console.error("Non-conventional commits detected:"); + for (const c of invalid) { + console.error(`- ${c.sha.slice(0, 7)} ${c.subject}`); + } + process.exit(1); + } + console.log("All commits follow conventional commit rules."); + return; + } + + let required: Bump = "none"; + for (const c of commits) required = maxBump(required, bumpForCommit(c)); + + const baseVersion = fromTag ? parseSemver(fromTag) : ([0, 1, 0] as [number, number, number]); + const nextVersion = fmtSemver(bump(baseVersion, required)); + const notes = buildNotes(commits); + + const out = { + from_tag: fromTag, + from_ref: rangeFrom || null, + to_ref: toRef, + commit_count: commits.length, + invalid_count: invalid.length, + bump: required, + next_version: nextVersion, + release_notes: notes, + }; + + if (githubOutput) { + const lines = [ + `from_tag=${out.from_tag || ""}`, + `commit_count=${out.commit_count}`, + `invalid_count=${out.invalid_count}`, + `bump=${out.bump}`, + `next_version=${out.next_version}`, + "release_notes< 0 && !allowInvalid) { + console.error("Non-conventional commits detected:"); + for (const c of invalid) { + console.error(`- ${c.sha.slice(0, 7)} ${c.subject}`); + } + process.exit(1); + } + + console.log(JSON.stringify(out, null, 2)); +} + +main();