Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
name: CI
name: CI Core

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

on:
push:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
30 changes: 30 additions & 0 deletions .github/workflows/commitlint.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
47 changes: 43 additions & 4 deletions .github/workflows/dev-main-pr-template.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Dev->Main PR Autofill
name: Dev->Main PR Metadata

on:
pull_request:
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -48,10 +76,21 @@ jobs:
`<!-- AUTO-GENERATED:COMMITS -->\n${commitsText}\n<!-- /AUTO-GENERATED:COMMITS -->`
);

const existingBody = context.payload.pull_request.body || "";
const preserveManual = /<!-- AUTO-GENERATED:COMMITS -->[\s\S]*?<!-- \/AUTO-GENERATED:COMMITS -->/m.test(existingBody);
const nextBody = preserveManual
? existingBody
.replace(/- Version: .*/m, `- Version: v${version}`)
.replace(
/<!-- AUTO-GENERATED:COMMITS -->[\s\S]*?<!-- \/AUTO-GENERATED:COMMITS -->/m,
`<!-- AUTO-GENERATED:COMMITS -->\n${commitsText}\n<!-- /AUTO-GENERATED:COMMITS -->`
)
: body;

await github.rest.pulls.update({
owner,
repo,
pull_number,
title,
body,
body: nextBody,
});
8 changes: 6 additions & 2 deletions .github/workflows/perf-bench.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -20,7 +24,7 @@ on:

jobs:
bench:
name: Run ci-small benchmark
name: Bench / ci-small
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down
119 changes: 19 additions & 100 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
name: Release

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

on:
push:
tags:
Expand Down Expand Up @@ -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<<EOF"
echo "$NOTES"
echo "EOF"
} >> "$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, '-') }}
38 changes: 38 additions & 0 deletions docs/CI_HARDENING_EXECUTION_PLAN.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading