mirror of
https://github.com/kucherenko/jscpd.git
synced 2026-09-19 08:11:03 +08:00
afea9b1cc6
Closes #1037. Adds rust/scripts/build-pypi-wheels.py, which repacks the eight jscpd-<platform>.tar.gz release tarballs into platform wheels: the same binary under <pkg>.data/scripts/ as both `jscpd` and `cpd`, one wheel per target, stdlib only, no maturin and no recompilation. glibc wheels take their manylinux floor from the GLIBC_x.y symbols in the binary (manylinux_2_34 today), musl builds are static so they tag as musllinux_1_2, macOS and Windows use the Rust minimums. Semver pre-releases map to PEP 440 (5.3.0-beta.1 -> 5.3.0b1); other labels fail rather than guess. release.yml gains a rust-publish-pypi job after the npm publish: it takes the binary artifacts from this run, refills any missing target from the published npm platform package (resumed releases, or a version already on npm), builds the wheels, pip-installs the matching one and checks `jscpd --version` and `cpd --version`, then uploads with PyPI trusted publishing (environment `pypi`, no token, skip-existing). The detect job treats a version missing from PyPI as pending, so the wheels for the current release publish on the next push to master without a new release commit. The GitHub release lists the PyPI package and waits on the upload the same way it waits on crates.io and npm. Docs: PyPI in the README install table and packages table, rust/README and docs/rust.md install sections, and a `language: python` pre-commit option in docs/ci-and-hooks.md so the hook no longer needs Node.js. Claude-Session: https://claude.ai/code/session_01WztdFKZAwW9b51iJe9zhtV
603 lines
25 KiB
YAML
603 lines
25 KiB
YAML
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 }}
|
|
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 }}
|
|
pypi_needs_publish: ${{ steps.detect.outputs.pypi_needs_publish }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 2
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
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
|
|
|
|
# ── File-change detection ──
|
|
if echo "$changed_files" | grep -q '^rust/'; then
|
|
rust_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 \
|
|
| jq -r '.crate.max_version // empty' || 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
|
|
|
|
# ── PyPI: publish if the wheels for this version are not on PyPI.
|
|
# Wheels are repacked from the npm platform binaries, so this never
|
|
# requires a rebuild; a version already on npm but missing on PyPI
|
|
# (e.g. the first release after PyPI support landed, or a failed
|
|
# upload) is published from the npm packages on the next push.
|
|
pypi_version=$(python3 scripts/build-pypi-wheels.py --pep440 "$rust_version")
|
|
pypi_needs_publish=false
|
|
if curl -sfo /dev/null -H "User-Agent: jscpd-ci (github.com/kucherenko/jscpd)" "https://pypi.org/pypi/jscpd/${pypi_version}/json"; then
|
|
echo "jscpd==${pypi_version} already on PyPI"
|
|
else
|
|
echo "PyPI release needed: jscpd==${pypi_version}"
|
|
pypi_needs_publish=true
|
|
rust_changed=true
|
|
fi
|
|
|
|
echo "rust_changed=${rust_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 "pypi_needs_publish=${pypi_needs_publish}" >> "$GITHUB_OUTPUT"
|
|
echo "Detected: rust_changed=${rust_changed}, crates_needs_publish=${crates_needs_publish}, npm_needs_publish=${npm_needs_publish}, pypi_needs_publish=${pypi_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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
fetch-depth: 2
|
|
|
|
- name: Install Rust toolchain
|
|
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
|
with:
|
|
toolchain: "1.97.1"
|
|
components: clippy
|
|
|
|
- name: Cache Cargo registry and target
|
|
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # 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
|
|
|
|
# ─── 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 }}
|
|
pypi_bumped: ${{ needs.detect.outputs.pypi_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 }}"
|
|
echo "pypi_needs_publish=${{ needs.detect.outputs.pypi_needs_publish }}"
|
|
|
|
# ─── 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
|
|
|
|
# ─── PyPI ────────────────────────────────────────────────────────
|
|
# Platform wheels repacked from the npm platform binaries (no build):
|
|
# rust/scripts/build-pypi-wheels.py puts the same `jscpd` binary under
|
|
# `<pkg>.data/scripts/` as `jscpd` and `cpd`, one wheel per target. The
|
|
# upload uses PyPI trusted publishing (OIDC): the `jscpd` project on PyPI
|
|
# trusts this repository's `release.yml` workflow via the `pypi` environment,
|
|
# so no API token is stored. Runs after the npm publish because that is
|
|
# where the binaries come from; the fallback below covers resumed releases
|
|
# whose binaries are already on npm.
|
|
rust-publish-pypi:
|
|
name: publish pypi wheels
|
|
needs: [rust-version, rust-publish-npm]
|
|
if: always() && !cancelled() &&
|
|
needs.rust-version.result == 'success' &&
|
|
needs.rust-version.outputs.pypi_bumped == 'true' &&
|
|
needs.rust-publish-npm.result != 'failure'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
environment:
|
|
name: pypi
|
|
url: https://pypi.org/project/jscpd/
|
|
permissions:
|
|
contents: read
|
|
id-token: write # PyPI trusted publishing + PEP 740 attestations
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Download binary artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: jscpd-binary-*
|
|
path: release-assets
|
|
merge-multiple: true
|
|
continue-on-error: true
|
|
|
|
# The npm build job only uploads artifacts for targets it built in THIS
|
|
# run. When the platform packages were published by an earlier run (a
|
|
# resumed release, or PyPI support landing after a version shipped),
|
|
# repack the published npm binaries into the same tarball layout.
|
|
- name: Fill in missing binaries from npm
|
|
run: |
|
|
version="${{ needs.rust-version.outputs.version }}"
|
|
mkdir -p release-assets
|
|
for key in $(node -e "console.log(Object.keys(require('./rust/npm/prebuilt-targets.json')).join(' '))"); do
|
|
asset="release-assets/jscpd-${key}.tar.gz"
|
|
if [ -f "$asset" ]; then
|
|
echo "${key}: using artifact from this run"
|
|
continue
|
|
fi
|
|
package_name="jscpd-${key}"
|
|
binary_name="jscpd"
|
|
case "$key" in windows-*) binary_name="jscpd.exe" ;; esac
|
|
echo "${key}: repacking ${package_name}@${version} from npm"
|
|
work="repack/${key}"
|
|
rm -rf "$work" && mkdir -p "$work/stage"
|
|
npm pack "${package_name}@${version}" --pack-destination "$work" >/dev/null
|
|
tar -xzf "$work"/*.tgz -C "$work"
|
|
bin_path=$(find "$work/package" -name "$binary_name" -type f | head -1)
|
|
[ -n "$bin_path" ] || { echo "::error::${binary_name} not found in ${package_name}@${version}"; exit 1; }
|
|
cp "$bin_path" "$work/stage/"
|
|
cp LICENSE "$work/stage/LICENSE"
|
|
chmod +x "$work/stage/$binary_name"
|
|
tar -czf "$asset" -C "$work/stage" "$binary_name" LICENSE
|
|
done
|
|
ls -1 release-assets
|
|
|
|
- name: Build wheels
|
|
run: |
|
|
python3 rust/scripts/build-pypi-wheels.py \
|
|
--version "${{ needs.rust-version.outputs.version }}" \
|
|
--assets-dir release-assets \
|
|
--out-dir dist
|
|
ls -la dist
|
|
|
|
# Install the wheel that matches this runner from the local directory
|
|
# only, and make sure the packaged binary is the release being shipped.
|
|
- name: Smoke test
|
|
run: |
|
|
version="${{ needs.rust-version.outputs.version }}"
|
|
pypi_version=$(python3 rust/scripts/build-pypi-wheels.py --pep440 "$version")
|
|
python3 -m venv .venv
|
|
.venv/bin/pip install --quiet --no-index --find-links dist "jscpd==${pypi_version}"
|
|
for cmd in jscpd cpd; do
|
|
installed=$(".venv/bin/${cmd}" --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "NOT_FOUND")
|
|
echo "${cmd}: ${installed}, expected ${version}"
|
|
[ "$installed" = "$version" ] || { echo "::error::${cmd} from the wheel reports ${installed}"; exit 1; }
|
|
done
|
|
.venv/bin/jscpd fixtures --silent --reporters console || true
|
|
|
|
- name: Upload wheels artifact
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: jscpd-wheels
|
|
path: dist/*.whl
|
|
retention-days: 1
|
|
|
|
- name: Publish to PyPI
|
|
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
with:
|
|
packages-dir: dist
|
|
# A resumed release may have uploaded some wheels already.
|
|
skip-existing: true
|
|
|
|
# ─── GitHub releases ─────────────────────────────────────────────
|
|
rust-release:
|
|
name: create rust github release
|
|
needs: [rust-version, rust-publish-crates, rust-publish-npm, rust-publish-pypi]
|
|
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' &&
|
|
needs.rust-publish-pypi.result != 'failure'
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
id-token: write # keyless signing for build provenance (OpenSSF Scorecard: Signed-Releases)
|
|
attestations: write
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Download binary artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: jscpd-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 \
|
|
| jq -r '.crate.max_version // empty' || 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 reads are eventually consistent: a package published seconds
|
|
# earlier can still be invisible here, which listed it under "Not Yet
|
|
# Published" in the release notes even though the publish succeeded
|
|
# (v5.1.0 hit this with the jscpd wrapper). Re-check only the packages
|
|
# still missing, against one shared deadline so a genuinely
|
|
# unpublished package can't stretch the wait.
|
|
npm_pkgs=(cpd jscpd jscpd-darwin-arm64 jscpd-darwin-x64 jscpd-linux-x64-gnu jscpd-linux-arm64-gnu jscpd-linux-x64-musl jscpd-linux-arm64-musl jscpd-windows-x64-msvc jscpd-windows-arm64-msvc)
|
|
missing=("${npm_pkgs[@]}")
|
|
resolved=""
|
|
deadline=$(( $(date +%s) + 300 ))
|
|
while true; do
|
|
still_missing=()
|
|
for pkg in "${missing[@]}"; do
|
|
pub_ver=$(npm view "${pkg}@${version}" version 2>/dev/null || echo "")
|
|
if [ "$pub_ver" = "$version" ]; then
|
|
resolved="${resolved} ${pkg}"
|
|
else
|
|
still_missing+=("${pkg}")
|
|
fi
|
|
done
|
|
missing=(${still_missing[@]+"${still_missing[@]}"})
|
|
if [ ${#missing[@]} -eq 0 ] || [ "$(date +%s)" -ge "$deadline" ]; then
|
|
break
|
|
fi
|
|
echo "Waiting for npm to serve: ${missing[*]}"
|
|
sleep 15
|
|
done
|
|
|
|
for pkg in "${npm_pkgs[@]}"; do
|
|
if [[ " ${resolved} " == *" ${pkg} "* ]]; 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
|
|
|
|
# ── PyPI ──
|
|
pypi_version=$(python3 scripts/build-pypi-wheels.py --pep440 "$version")
|
|
if curl -sfo /dev/null -H "User-Agent: jscpd-ci (github.com/kucherenko/jscpd)" "https://pypi.org/pypi/jscpd/${pypi_version}/json"; then
|
|
published="${published} - \`jscpd==${pypi_version}\` on PyPI\n"
|
|
else
|
|
failed="${failed} - \`jscpd==${pypi_version}\` on PyPI\n"
|
|
fi
|
|
|
|
{
|
|
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
|
|
|
|
# ── Verification instructions (only when binary assets ship) ──
|
|
# Quoted heredoc (the snippet contains backticks) written to a file
|
|
# rather than captured with $(...): bash 3.x drops the backslash
|
|
# line continuations inside a heredoc in a command substitution.
|
|
if ls ../release-assets/*.tar.gz >/dev/null 2>&1; then
|
|
verify_file="${RUNNER_TEMP:-/tmp}/jscpd-release-verify.md"
|
|
cat > "$verify_file" <<'VERIFY_EOF'
|
|
## Verify
|
|
|
|
Archives are signed with [Sigstore](https://sigstore.dev) (keyless, `<asset>.sigstore.json`)
|
|
and carry SLSA build provenance. Replace `jscpd-linux-x64-gnu.tar.gz` with your asset:
|
|
|
|
```bash
|
|
cosign verify-blob \
|
|
--bundle jscpd-linux-x64-gnu.tar.gz.sigstore.json \
|
|
--certificate-identity-regexp '^https://github\.com/kucherenko/jscpd/' \
|
|
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
|
jscpd-linux-x64-gnu.tar.gz
|
|
gh attestation verify jscpd-linux-x64-gnu.tar.gz --repo kucherenko/jscpd
|
|
sha256sum --check --ignore-missing checksums.txt
|
|
```
|
|
VERIFY_EOF
|
|
notes="${notes}
|
|
|
|
$(cat "$verify_file")"
|
|
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" \
|
|
--latest \
|
|
$assets
|
|
else
|
|
gh release create "${tag}" \
|
|
--title "Release v${version}" \
|
|
--notes "$RELEASE_NOTES" \
|
|
--latest
|
|
fi
|
|
|
|
- name: Generate checksums
|
|
id: checksums
|
|
run: |
|
|
if [ -d release-assets ] && ls release-assets/*.tar.gz >/dev/null 2>&1; then
|
|
(cd release-assets && sha256sum *.tar.gz > checksums.txt)
|
|
echo "have=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "have=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# Signed SLSA provenance for the release binaries, verifiable with
|
|
# `gh attestation verify <asset> --repo kucherenko/jscpd`. The bundle is
|
|
# also uploaded as a release asset (multiple.intoto.jsonl) so provenance
|
|
# travels with the artifacts (and OpenSSF Scorecard Signed-Releases
|
|
# detects it).
|
|
- name: Attest build provenance
|
|
if: steps.checksums.outputs.have == 'true'
|
|
id: attest
|
|
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
|
with:
|
|
subject-path: release-assets/*.tar.gz
|
|
|
|
# Sigstore keyless signatures (in addition to the SLSA attestation) so
|
|
# assets can be verified with plain `cosign verify-blob` and no GitHub
|
|
# CLI. One `<asset>.sigstore.json` bundle per archive plus checksums.txt;
|
|
# the job's `id-token: write` grant is what makes the OIDC flow work.
|
|
- name: Install cosign
|
|
if: steps.checksums.outputs.have == 'true'
|
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
|
|
|
- name: Sign release assets (sigstore keyless)
|
|
if: steps.checksums.outputs.have == 'true'
|
|
working-directory: release-assets
|
|
run: |
|
|
for asset in *.tar.gz checksums.txt; do
|
|
cosign sign-blob --yes --bundle "${asset}.sigstore.json" "$asset"
|
|
done
|
|
ls -1 ./*.sigstore.json
|
|
|
|
- name: Upload checksums, provenance and signatures to release
|
|
if: steps.checksums.outputs.have == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
tag="v${{ needs.rust-version.outputs.version }}"
|
|
cp "${{ steps.attest.outputs.bundle-path }}" release-assets/multiple.intoto.jsonl
|
|
gh release upload "${tag}" \
|
|
release-assets/checksums.txt \
|
|
release-assets/multiple.intoto.jsonl \
|
|
release-assets/*.sigstore.json
|
|
|
|
# The composite GitHub Action (`uses: kucherenko/jscpd@v5`) follows a
|
|
# floating major tag. Move it to this release's commit for every stable
|
|
# Rust-line (5.x and later) release; pre-releases never move it.
|
|
- name: Update floating major tag
|
|
env:
|
|
VERSION: ${{ needs.rust-version.outputs.version }}
|
|
run: |
|
|
case "$VERSION" in
|
|
*-*) echo "pre-release ${VERSION}: leaving the floating major tag alone"; exit 0 ;;
|
|
esac
|
|
major="${VERSION%%.*}"
|
|
if [ "$major" -lt 5 ]; then
|
|
echo "v${VERSION} is not on the Rust line; leaving the floating major tag alone"
|
|
exit 0
|
|
fi
|
|
git tag -f "v${major}" "$GITHUB_SHA"
|
|
git push -f origin "refs/tags/v${major}"
|
|
echo "v${major} -> ${GITHUB_SHA}"
|
|
|
|
rust-docker:
|
|
name: publish docker image
|
|
needs: [rust-version, rust-release]
|
|
# Releases created with GITHUB_TOKEN never fire `release` events, so the
|
|
# image workflow is invoked directly.
|
|
if: needs.rust-release.result == 'success' && needs.rust-version.outputs.npm_bumped == 'true'
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
id-token: write
|
|
attestations: write
|
|
uses: ./.github/workflows/docker.yml
|
|
with:
|
|
version: ${{ needs.rust-version.outputs.version }}
|