Merge pull request #850 from laurynas-biveinis/fix-lisp-comment-style #292
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| branches: | |
| - master | |
| concurrency: | |
| group: release | |
| cancel-in-progress: false | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| permissions: | |
| contents: read | |
| defaults: | |
| run: | |
| shell: bash | |
| jobs: | |
| # ─── Detect changed paths & pending releases ────────────────────────── | |
| detect: | |
| name: detect changed paths | |
| runs-on: ubuntu-latest | |
| outputs: | |
| rust_changed: ${{ steps.detect.outputs.rust_changed }} | |
| ts_changed: ${{ steps.detect.outputs.ts_changed }} | |
| rust_version: ${{ steps.detect.outputs.rust_version }} | |
| crates_needs_publish: ${{ steps.detect.outputs.crates_needs_publish }} | |
| npm_needs_publish: ${{ steps.detect.outputs.npm_needs_publish }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 2 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: "22" | |
| package-manager-cache: false | |
| - name: Sync versions from package.json | |
| working-directory: rust | |
| run: node scripts/sync-version.mjs | |
| - name: Detect changed paths & pending releases | |
| id: detect | |
| working-directory: rust | |
| run: | | |
| changed_files=$(git -C .. diff --name-only HEAD~1 HEAD) | |
| rust_changed=false | |
| ts_changed=false | |
| # ── File-change detection ── | |
| if echo "$changed_files" | grep -q '^rust/'; then | |
| rust_changed=true | |
| fi | |
| ts_files=$(echo "$changed_files" | grep -v '^rust/' || true) | |
| if [ -n "$ts_files" ]; then | |
| ts_changed=true | |
| fi | |
| # ── Registry-based release detection (Rust) ── | |
| # If local version is not yet on crates.io or npm, we need a release | |
| rust_version=$(node -p "require('./package.json').version") | |
| crates_needs_publish=false | |
| npm_needs_publish=false | |
| for toml in crates/*/Cargo.toml; do | |
| name=$(grep '^name = ' "$toml" | head -1 | sed 's/name = "\(.*\)"/\1/') | |
| local_ver=$(grep '^version = ' "$toml" | head -1 | sed 's/version = "\(.*\)"/\1/') | |
| if [ -z "$name" ] || [ -z "$local_ver" ]; then | |
| continue | |
| fi | |
| # Use the crates.io HTTP API for exact version matching. | |
| # cargo info crate@ver exits 0 if the crate exists at ANY version, | |
| # not just the specified one — so we must check the versions list. | |
| published_ver=$(curl -sfH "Accept: application/json" -H "User-Agent: jscpd-ci (github.com/kucherenko/jscpd)" "https://crates.io/api/v1/crates/${name}" 2>/dev/null \ | |
| | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const c=JSON.parse(d);process.stdout.write(c.crate.max_version)}catch(e){process.stdout.write('')}})" || echo "") | |
| if [ "$published_ver" = "$local_ver" ] && [ -n "$published_ver" ]; then | |
| echo "${name}@${local_ver} already on crates.io (max_version=${published_ver})" | |
| else | |
| echo "crates.io release needed: ${name}@${local_ver} (published=${published_ver:-none})" | |
| crates_needs_publish=true | |
| rust_changed=true | |
| fi | |
| done | |
| # ── npm: publish if rust/package.json version differs from npm registry | |
| npm_published=$(npm info cpd version 2>/dev/null || echo "") | |
| if [ "$npm_published" != "$rust_version" ]; then | |
| echo "npm release needed: cpd published=${npm_published:-none} local=${rust_version}" | |
| npm_needs_publish=true | |
| rust_changed=true | |
| else | |
| echo "cpd@${rust_version} already on npm" | |
| fi | |
| echo "rust_changed=${rust_changed}" >> "$GITHUB_OUTPUT" | |
| echo "ts_changed=${ts_changed}" >> "$GITHUB_OUTPUT" | |
| echo "rust_version=${rust_version}" >> "$GITHUB_OUTPUT" | |
| echo "crates_needs_publish=${crates_needs_publish}" >> "$GITHUB_OUTPUT" | |
| echo "npm_needs_publish=${npm_needs_publish}" >> "$GITHUB_OUTPUT" | |
| echo "Detected: rust_changed=${rust_changed}, ts_changed=${ts_changed}, crates_needs_publish=${crates_needs_publish}, npm_needs_publish=${npm_needs_publish}" | |
| # ─── Rust CI ───────────────────────────────────────────────────── | |
| rust-ci: | |
| name: rust ci (clippy, test, build) | |
| needs: detect | |
| if: needs.detect.outputs.rust_changed == 'true' | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: rust | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 2 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| toolchain: "1.93" | |
| components: clippy | |
| - name: Cache Cargo registry and target | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: "rust -> rust/target" | |
| cache-on-failure: true | |
| - name: Clippy | |
| run: cargo clippy --workspace -- -D warnings | |
| - name: Test | |
| run: cargo test --workspace --locked | |
| - name: Build | |
| run: cargo build --release --workspace --locked | |
| # ─── TypeScript CI ────────────────────────────────────────────── | |
| ts-ci: | |
| name: typescript ci (build, lint, test) | |
| needs: detect | |
| if: needs.detect.outputs.ts_changed == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| checks: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 2 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: "lts/*" | |
| package-manager-cache: false | |
| - name: Install dependencies | |
| run: pnpm install | |
| - name: Build | |
| run: pnpm build | |
| - name: Lint | |
| run: pnpm lint | |
| - name: Test | |
| run: pnpm test -- --reporter=default --reporter=junit --outputFile=test-results/junit.xml | |
| - name: Upload test results | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-results-release-ts | |
| path: "**/test-results/junit.xml" | |
| if-no-files-found: warn | |
| - name: Test Report | |
| if: always() | |
| uses: dorny/test-reporter@v1 | |
| with: | |
| name: Node.js Tests (release) | |
| path: "**/test-results/junit.xml" | |
| reporter: java-junit | |
| fail-on-error: false | |
| # ─── Rust version bump detection ───────────────────────────────── | |
| rust-version: | |
| name: detect rust version bump | |
| needs: [detect, rust-ci] | |
| if: needs.detect.outputs.rust_changed == 'true' && needs.rust-ci.result == 'success' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ needs.detect.outputs.rust_version }} | |
| crates_bumped: ${{ needs.detect.outputs.crates_needs_publish }} | |
| npm_bumped: ${{ needs.detect.outputs.npm_needs_publish }} | |
| steps: | |
| - name: Confirm | |
| run: | | |
| echo "Releasing Rust ${{ needs.detect.outputs.rust_version }}" | |
| echo "crates_needs_publish=${{ needs.detect.outputs.crates_needs_publish }}" | |
| echo "npm_needs_publish=${{ needs.detect.outputs.npm_needs_publish }}" | |
| # ─── TypeScript version bump detection ─────────────────────────── | |
| ts-version: | |
| name: detect typescript version bump | |
| needs: [detect, ts-ci] | |
| if: needs.detect.outputs.ts_changed == 'true' && needs.ts-ci.result == 'success' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.version.outputs.version }} | |
| bumped: ${{ steps.version.outputs.bumped }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 2 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: "22" | |
| package-manager-cache: false | |
| - name: Detect version bump | |
| id: version | |
| run: | | |
| version=$(node -p "require('./package.json').version") | |
| bumped=false | |
| # Check root package.json | |
| old_root=$(git show "HEAD~1:package.json" 2>/dev/null | node -e " | |
| let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ | |
| try{ process.stdout.write(JSON.parse(d).version) }catch(e){ process.exit(0) } | |
| })" || true) | |
| new_root=$(node -p "require('./package.json').version") | |
| if [ -n "$old_root" ] && [ "$old_root" != "$new_root" ]; then | |
| echo "Version bump in package.json: $old_root -> $new_root" | |
| bumped=true | |
| fi | |
| # Check apps/*/package.json | |
| for pkg in apps/*/package.json; do | |
| old_v=$(git show "HEAD~1:$pkg" 2>/dev/null | node -e " | |
| let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ | |
| try{ process.stdout.write(JSON.parse(d).version) }catch(e){ process.exit(0) } | |
| })" || true) | |
| new_v=$(node -p "require('./$pkg').version") | |
| if [ -n "$old_v" ] && [ "$old_v" != "$new_v" ]; then | |
| echo "Version bump in $pkg: $old_v -> $new_v" | |
| bumped=true | |
| fi | |
| done | |
| # Check packages/*/package.json (skip private packages) | |
| for pkg in packages/*/package.json; do | |
| is_private=$(node -p "require('./$pkg').private || false" 2>/dev/null || echo "true") | |
| if [ "$is_private" = "true" ]; then | |
| continue | |
| fi | |
| old_v=$(git show "HEAD~1:$pkg" 2>/dev/null | node -e " | |
| let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{ | |
| try{ process.stdout.write(JSON.parse(d).version) }catch(e){ process.exit(0) } | |
| })" || true) | |
| new_v=$(node -p "require('./$pkg').version") | |
| if [ -n "$old_v" ] && [ "$old_v" != "$new_v" ]; then | |
| echo "Version bump in $pkg: $old_v -> $new_v" | |
| bumped=true | |
| fi | |
| done | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| echo "bumped=$bumped" >> "$GITHUB_OUTPUT" | |
| # ─── Rust publish ──────────────────────────────────────────────── | |
| rust-publish-crates: | |
| name: publish crates.io packages | |
| needs: rust-version | |
| if: needs.rust-version.outputs.crates_bumped == 'true' | |
| uses: ./.github/workflows/crates-publish.yml | |
| with: | |
| version: ${{ needs.rust-version.outputs.version }} | |
| secrets: inherit | |
| permissions: | |
| contents: read | |
| id-token: write | |
| checks: write | |
| rust-publish-npm: | |
| name: publish npm rust packages | |
| needs: rust-version | |
| if: needs.rust-version.outputs.npm_bumped == 'true' | |
| uses: ./.github/workflows/npm-rust-publish.yml | |
| with: | |
| version: ${{ needs.rust-version.outputs.version }} | |
| secrets: inherit | |
| permissions: | |
| contents: read | |
| id-token: write | |
| checks: write | |
| # ─── TypeScript publish ────────────────────────────────────────── | |
| ts-publish: | |
| name: publish npm packages | |
| needs: ts-version | |
| if: needs.ts-version.outputs.bumped == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| id-token: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: "lts/*" | |
| registry-url: "https://registry.npmjs.org" | |
| package-manager-cache: false | |
| - name: Install dependencies | |
| run: pnpm install | |
| - name: Build | |
| run: pnpm build | |
| - name: Publish packages | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: pnpm publish-packages | |
| # ─── GitHub releases ───────────────────────────────────────────── | |
| rust-release: | |
| name: create rust github release | |
| needs: [rust-version, rust-publish-crates, rust-publish-npm] | |
| if: always() && !cancelled() && | |
| (needs.rust-version.outputs.crates_bumped == 'true' || needs.rust-version.outputs.npm_bumped == 'true') && | |
| needs.rust-publish-crates.result != 'failure' && | |
| needs.rust-publish-npm.result != 'failure' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Download binary artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: cpd-binary-* | |
| path: release-assets | |
| merge-multiple: true | |
| continue-on-error: true | |
| - name: Resolve published packages | |
| id: packages | |
| working-directory: rust | |
| run: | | |
| version="${{ needs.rust-version.outputs.version }}" | |
| published="" | |
| failed="" | |
| # ── crates.io packages ── | |
| for toml in crates/*/Cargo.toml; do | |
| name=$(grep '^name = ' "$toml" | head -1 | sed 's/name = "\(.*\)"/\1/') | |
| local_ver=$(grep '^version = ' "$toml" | head -1 | sed 's/version = "\(.*\)"/\1/') | |
| if [ -z "$name" ] || [ -z "$local_ver" ]; then | |
| continue | |
| fi | |
| max_ver=$(curl -sfH "Accept: application/json" -H "User-Agent: jscpd-ci (github.com/kucherenko/jscpd)" "https://crates.io/api/v1/crates/${name}" 2>/dev/null \ | |
| | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const c=JSON.parse(d);process.stdout.write(c.crate.max_version)}catch(e){process.stdout.write('')}})" || echo "") | |
| if [ "$max_ver" = "$local_ver" ]; then | |
| published="${published} - \`${name}@${local_ver}\` on crates.io\n" | |
| else | |
| failed="${failed} - \`${name}@${local_ver}\` (published: ${max_ver:-none})\n" | |
| fi | |
| done | |
| # ── npm packages ── | |
| npm_pkgs=(cpd jscpd cpd-darwin-arm64 cpd-darwin-x64 cpd-linux-x64-gnu cpd-linux-arm64-gnu cpd-linux-x64-musl cpd-windows-x64-msvc) | |
| for pkg in "${npm_pkgs[@]}"; do | |
| pub_ver=$(npm view "${pkg}@${version}" version 2>/dev/null || echo "") | |
| if [ "$pub_ver" = "$version" ]; then | |
| published="${published} - \`${pkg}@${version}\` on npm\n" | |
| else | |
| # Platform packages may have different versions | |
| latest_ver=$(npm view "${pkg}" version 2>/dev/null || echo "") | |
| failed="${failed} - \`${pkg}@${version}\` (published: ${latest_ver:-none})\n" | |
| fi | |
| done | |
| { | |
| echo "published<<EOF" | |
| echo -e "$published" | |
| echo "EOF" | |
| echo "failed<<EOF" | |
| echo -e "$failed" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Extract release notes | |
| id: notes | |
| working-directory: rust | |
| run: | | |
| version="${{ needs.rust-version.outputs.version }}" | |
| notes=$(node -e " | |
| const fs = require('fs'); | |
| const ver = process.argv[1]; | |
| const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); | |
| const escaped = ver.replace(/\./g, '\\\\.'); | |
| const re = new RegExp('^## .*' + escaped); | |
| const lines = changelog.split('\n'); | |
| let found = false; | |
| const out = []; | |
| for (const line of lines) { | |
| if (!found && re.test(line)) { found = true; continue; } | |
| if (found && /^## /.test(line)) break; | |
| if (found && /^---$/.test(line)) break; | |
| if (found) out.push(line); | |
| } | |
| process.stdout.write(out.join('\n')); | |
| " "$version") | |
| if [ -z "$notes" ]; then | |
| notes="Release v${version}" | |
| fi | |
| # ── Append published packages info ── | |
| # Use single-quoted assignments so markdown backticks in package | |
| # names are not interpreted as command substitution. | |
| published='${{ steps.packages.outputs.published }}' | |
| failed='${{ steps.packages.outputs.failed }}' | |
| if [ -n "$published" ]; then | |
| notes="${notes} | |
| ## Published Packages | |
| ${published}" | |
| fi | |
| if [ -n "$failed" ]; then | |
| notes="${notes} | |
| ## Not Yet Published | |
| ${failed}" | |
| fi | |
| { | |
| echo "notes<<EOF" | |
| echo "$notes" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Create GitHub Release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RELEASE_NOTES: ${{ steps.notes.outputs.notes }} | |
| run: | | |
| version="${{ needs.rust-version.outputs.version }}" | |
| tag="v${version}" | |
| # Collect binary assets if they exist | |
| assets="" | |
| if [ -d release-assets ] && ls release-assets/*.tar.gz >/dev/null 2>&1; then | |
| for f in release-assets/*.tar.gz; do | |
| assets="$assets $f" | |
| done | |
| fi | |
| if [ -n "$assets" ]; then | |
| gh release create "${tag}" \ | |
| --title "Release v${version}" \ | |
| --notes "$RELEASE_NOTES" \ | |
| $assets | |
| else | |
| gh release create "${tag}" \ | |
| --title "Release v${version}" \ | |
| --notes "$RELEASE_NOTES" | |
| fi | |
| ts-release: | |
| name: create typescript github release | |
| needs: [ts-version, ts-publish] | |
| if: needs.ts-version.outputs.bumped == 'true' && needs.ts-publish.result == 'success' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Resolve published npm packages | |
| id: packages | |
| run: | | |
| version="${{ needs.ts-version.outputs.version }}" | |
| published="" | |
| failed="" | |
| # ── TypeScript workspace packages ── | |
| for pkg_dir in apps/*/ packages/*/; do | |
| pkg_json="${pkg_dir}package.json" | |
| [ -f "$pkg_json" ] || continue | |
| is_private=$(node -p "require('./${pkg_json}').private || false" 2>/dev/null || echo "true") | |
| [ "$is_private" = "true" ] && continue | |
| name=$(node -p "require('./${pkg_json}').name" 2>/dev/null || continue) | |
| local_ver=$(node -p "require('./${pkg_json}').version" 2>/dev/null || continue) | |
| pub_ver=$(npm view "${name}@${local_ver}" version 2>/dev/null || echo "") | |
| if [ "$pub_ver" = "$local_ver" ]; then | |
| published="${published} - \`${name}@${local_ver}\` on npm\n" | |
| else | |
| latest_ver=$(npm view "${name}" version 2>/dev/null || echo "") | |
| failed="${failed} - \`${name}@${local_ver}\` (published: ${latest_ver:-none})\n" | |
| fi | |
| done | |
| { | |
| echo "published<<EOF" | |
| echo -e "$published" | |
| echo "EOF" | |
| echo "failed<<EOF" | |
| echo -e "$failed" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Extract release notes | |
| id: notes | |
| run: | | |
| version="${{ needs.ts-version.outputs.version }}" | |
| notes=$(node -e " | |
| const fs = require('fs'); | |
| const ver = process.argv[1]; | |
| const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); | |
| const escaped = ver.replace(/\./g, '\\\\.'); | |
| const re = new RegExp('^## .*' + escaped); | |
| const lines = changelog.split('\n'); | |
| let found = false; | |
| const out = []; | |
| for (const line of lines) { | |
| if (!found && re.test(line)) { found = true; continue; } | |
| if (found && /^## /.test(line)) break; | |
| if (found && /^---$/.test(line)) break; | |
| if (found) out.push(line); | |
| } | |
| process.stdout.write(out.join('\n')); | |
| " "$version") | |
| if [ -z "$notes" ]; then | |
| notes="Release v${version}" | |
| fi | |
| # ── Append published packages info ── | |
| # Use single-quoted assignments so markdown backticks in package | |
| # names are not interpreted as command substitution. | |
| published='${{ steps.packages.outputs.published }}' | |
| failed='${{ steps.packages.outputs.failed }}' | |
| if [ -n "$published" ]; then | |
| notes="${notes} | |
| ## Published Packages | |
| ${published}" | |
| fi | |
| if [ -n "$failed" ]; then | |
| notes="${notes} | |
| ## Not Yet Published | |
| ${failed}" | |
| fi | |
| { | |
| echo "notes<<EOF" | |
| echo "$notes" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Create GitHub Release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RELEASE_NOTES: ${{ steps.notes.outputs.notes }} | |
| run: | | |
| version="${{ needs.ts-version.outputs.version }}" | |
| tag="v${version}" | |
| gh release create "${tag}" \ | |
| --title "Release v${version}" \ | |
| --notes "$RELEASE_NOTES" |