Skip to content

Merge pull request #848 from kucherenko/dependabot/npm_and_yarn/turbo… #291

Merge pull request #848 from kucherenko/dependabot/npm_and_yarn/turbo…

Merge pull request #848 from kucherenko/dependabot/npm_and_yarn/turbo… #291

Workflow file for this run

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"