diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1889438..608aa71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,20 +2,32 @@ name: CI on: pull_request: - push: + types: + - opened + - synchronize + - reopened + - closed branches: + - master - main + push: + branches: - master + - main + +permissions: + contents: read jobs: - verify: + test: + name: Test and coverage runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Node + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 24 @@ -24,19 +36,284 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile - - name: Lint + - name: Run lint run: yarn lint - - name: Typecheck + - name: Run typecheck run: yarn typecheck - - name: Test - run: yarn test + - name: Run tests with coverage + run: yarn build && yarn c8 --all --src src --include "src/**/*.ts" --extension .ts --exclude-after-remap --reporter text --reporter lcov --check-coverage=false node --test "tests/**/*.test.mjs" - name: Build docs run: yarn docs:build - - name: Upload coverage + - name: Check npm package contents + run: npm pack --dry-run + + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: files: ./coverage/lcov.info + slug: haskou/ddd-kernel + verbose: true + fail_ci_if_error: false + + publish: + name: Publish to npm + runs-on: ubuntu-latest + needs: test + concurrency: + group: publish-${{ github.repository }}-${{ github.event.pull_request.base.ref }} + cancel-in-progress: false + permissions: + contents: write + id-token: write + pull-requests: read + if: >- + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + ( + github.event.pull_request.base.ref == 'master' || + github.event.pull_request.base.ref == 'main' + ) && + ( + github.event.pull_request.head.ref == 'feat' || + startsWith(github.event.pull_request.head.ref, 'feat/') || + github.event.pull_request.head.ref == 'fix' || + startsWith(github.event.pull_request.head.ref, 'fix/') || + github.event.pull_request.head.ref == 'break' || + startsWith(github.event.pull_request.head.ref, 'break/') + ) + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.base.ref }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: yarn + registry-url: https://registry.npmjs.org/ + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build package + run: yarn build + + - name: Compute release version + id: release + shell: bash + run: | + set -euo pipefail + + package_name="$(node -p "require('./package.json').name")" + current_version="$(node -p "require('./package.json').version")" + published_version="$(npm view "$package_name" version 2>/dev/null || true)" + branch_name="${HEAD_BRANCH}" + release_marker="for #${PR_NUMBER}" + + echo "already_released=false" >> "$GITHUB_OUTPUT" + + case "$branch_name" in + fix|fix/*) + bump="patch" + ;; + feat|feat/*) + bump="minor" + ;; + break|break/*) + bump="major" + ;; + *) + echo "Unsupported release branch: $branch_name" + exit 1 + ;; + esac + + release_subject="$(git log --format=%s --grep="$release_marker" -n 1 HEAD || true)" + + if [ -n "$release_subject" ] && grep -q "$release_marker" <<< "$release_subject"; then + next_version="$( + node - "$release_subject" "$current_version" <<'NODE' + const [subject, fallbackVersion] = process.argv.slice(2); + const match = subject.match(/Release v([^ ]+) for #\d+/); + + process.stdout.write(match?.[1] ?? fallbackVersion); + NODE + )" + + echo "Release for PR #${PR_NUMBER} already exists." + echo "already_released=true" >> "$GITHUB_OUTPUT" + echo "package_name=$package_name" >> "$GITHUB_OUTPUT" + echo "current_version=$current_version" >> "$GITHUB_OUTPUT" + echo "published_version=$published_version" >> "$GITHUB_OUTPUT" + echo "next_version=$next_version" >> "$GITHUB_OUTPUT" + echo "bump=$bump" >> "$GITHUB_OUTPUT" + echo "should_publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -z "$published_version" ]; then + next_version="$current_version" + should_publish="true" + bump="initial" + else + next_version="$( + node - "$current_version" "$bump" <<'NODE' + const [version, bump] = process.argv.slice(2); + const parts = version.split('.').map(Number); + + if (parts.length !== 3 || parts.some((part) => !Number.isInteger(part))) { + throw new Error(`Unsupported npm version: ${version}`); + } + + if (bump === 'patch') parts[2] += 1; + if (bump === 'minor') { + parts[1] += 1; + parts[2] = 0; + } + if (bump === 'major') { + parts[0] += 1; + parts[1] = 0; + parts[2] = 0; + } + + process.stdout.write(parts.join('.')); + NODE + )" + + should_publish="true" + + if [ "$published_version" = "$next_version" ]; then + should_publish="false" + fi + fi + + if [ "$current_version" != "$next_version" ]; then + npm version "$next_version" --no-git-tag-version + fi + + if [ -n "$published_version" ] && [ "$should_publish" = "true" ]; then + node - "$published_version" "$next_version" <<'NODE' + const [publishedVersion, nextVersion] = process.argv.slice(2); + const parse = (version) => version.split('.').map(Number); + const published = parse(publishedVersion); + const next = parse(nextVersion); + const comparison = published.findIndex((part, index) => part !== next[index]); + + if (comparison !== -1 && published[comparison] > next[comparison]) { + throw new Error( + `Published npm version ${publishedVersion} is newer than computed release ${nextVersion}`, + ); + } + NODE + fi + + echo "package_name=$package_name" >> "$GITHUB_OUTPUT" + echo "current_version=$current_version" >> "$GITHUB_OUTPUT" + echo "published_version=$published_version" >> "$GITHUB_OUTPUT" + echo "next_version=$next_version" >> "$GITHUB_OUTPUT" + echo "bump=$bump" >> "$GITHUB_OUTPUT" + echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT" + env: + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + + - name: Publish package + if: >- + steps.release.outputs.already_released != 'true' && + steps.release.outputs.should_publish == 'true' + run: npm publish --access public --tag latest + + - name: Commit release version + if: steps.release.outputs.already_released != 'true' + shell: bash + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add package.json + + if [ -f yarn.lock ]; then + git add yarn.lock + fi + + if git diff --cached --quiet; then + echo "No package version changes to commit." + else + git commit -m "chore(release): 🔖 Release v${NEXT_VERSION} for #${PR_NUMBER}" + fi + + if git rev-parse "v${NEXT_VERSION}" >/dev/null 2>&1; then + echo "Tag v${NEXT_VERSION} already exists." + else + git tag -a "v${NEXT_VERSION}" -m "Release v${NEXT_VERSION} for #${PR_NUMBER}" + fi + + auth_header="$( + node -e "process.stdout.write('AUTHORIZATION: basic ' + Buffer.from('x-access-token:' + process.env.GITHUB_TOKEN).toString('base64'))" + )" + git -c "http.https://github.com/.extraheader=$auth_header" push origin "HEAD:${BASE_BRANCH}" + git -c "http.https://github.com/.extraheader=$auth_header" push origin "v${NEXT_VERSION}" + env: + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + NEXT_VERSION: ${{ steps.release.outputs.next_version }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Create GitHub release notes + if: steps.release.outputs.next_version != '' + shell: bash + run: | + set -euo pipefail + + tag="v${NEXT_VERSION}" + release_notes="$(mktemp)" + pr_title="$(gh pr view "$PR_NUMBER" --json title --jq '.title')" + pr_body="$(gh pr view "$PR_NUMBER" --json body --jq '.body // ""')" + pr_url="$(gh pr view "$PR_NUMBER" --json url --jq '.url')" + + { + echo "## Changes" + echo + printf -- "- %s\n" "$pr_title" + echo + echo "## Release details" + echo + printf -- "- npm: https://www.npmjs.com/package/%s/v/%s\n" "$PACKAGE_NAME" "$NEXT_VERSION" + printf -- "- Pull request: %s\n" "$pr_url" + printf -- "- Source branch: \`%s\`\n" "$HEAD_BRANCH" + printf -- "- Version bump: \`%s\`\n" "$BUMP" + } > "$release_notes" + + if [ -n "$pr_body" ]; then + { + echo + echo "## Pull request notes" + echo + printf '%s\n' "$pr_body" | sed '/^------$/,$d' + } >> "$release_notes" + fi + + if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --title "$tag" --notes-file "$release_notes" + else + gh release create "$tag" --verify-tag --title "$tag" --notes-file "$release_notes" + fi + env: + BUMP: ${{ steps.release.outputs.bump }} + GH_TOKEN: ${{ github.token }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + NEXT_VERSION: ${{ steps.release.outputs.next_version }} + PACKAGE_NAME: ${{ steps.release.outputs.package_name }} + PR_NUMBER: ${{ github.event.pull_request.number }}