diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d459d5a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: { interval: weekly } + groups: + actions: + patterns: ["*"] + labels: ["deps", "ci"] + + - package-ecosystem: cargo + directory: "/" + schedule: { interval: weekly } + open-pull-requests-limit: 10 + groups: + cargo-minor: + update-types: ["minor", "patch"] + labels: ["deps", "rust"] + + - package-ecosystem: npm + directory: "/npm/facade" + schedule: { interval: weekly } + labels: ["deps", "npm"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2fedce8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: ci + +on: + push: + branches: [master] + pull_request: + merge_group: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + fmt: + name: fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.95" + components: rustfmt + # dprint's exec plugin shells out to these formatters; they must + # all be on PATH before dprint/check runs. + - uses: extractions/setup-just@v3 + - uses: taiki-e/install-action@v2 + with: + tool: tombi,shfmt + - run: cargo fmt --all -- --check + - uses: dprint/check@v2.3 + - run: tombi format --check + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.95" + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --all-targets --all-features -- -D warnings + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Windows excluded: pre-existing tests in src/ depend on POSIX + # path semantics (e.g. PathBuf comparisons against "/tmp/..." in + # src/lib.rs and src/cli.rs). The release smoke job in + # release.yml still exercises the produced Windows binaries. + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@master + with: { toolchain: "1.95" } + - uses: Swatinem/rust-cache@v2 + with: + shared-key: test-${{ matrix.os }} + - run: cargo test --all-features --no-fail-fast + + # rustdoc is intentionally not a required check: src/ has pre-existing + # `argv[0]` style doc comments that trip lints.rustdoc.broken_intra_doc_links. + # Track via TODO; re-enable once those are escaped. + + # npm packaging dry-run is disabled until npm/scripts/build-packages.ts + # is committed. justfile:9 references it but the file isn't in the + # repo; running `just build-packages` errors with ENOENT. The full + # build_npm job in release.yml will surface the same gap on a real + # release, which is the correct place for it to fail loudly. + + install-sh: + name: install.sh shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - run: shellcheck install.sh + + ci-pass: + name: ci pass + if: always() + needs: [fmt, lint, test, install-sh] + runs-on: ubuntu-latest + steps: + - name: Check matrix results + run: | + if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more required jobs failed." + exit 1 + fi diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 0000000..d65d0ba --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,56 @@ +name: release-plz + +on: + push: + branches: [master] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-plz-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Opens / updates the "release: prepare vX.Y.Z" PR. + release-plz-pr: + name: open release PR + runs-on: ubuntu-latest + if: ${{ !startsWith(github.event.head_commit.message, 'release:') }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@master + with: { toolchain: "1.95" } + - uses: MarcoIeni/release-plz-action@v0.5 + with: + command: release-pr + config: release-plz.toml + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # When the release-plz PR merges, this tags v{version}, creates the + # *draft* GitHub release, and publishes the crate to crates.io. The + # release event then triggers .github/workflows/release.yml which + # attaches binaries, publishes npm, and flips the release to public. + release-plz-release: + name: tag + crates.io + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@master + with: { toolchain: "1.95" } + - uses: MarcoIeni/release-plz-action@v0.5 + with: + command: release + config: release-plz.toml + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e957600 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,387 @@ +name: release + +on: + release: + types: [created] + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to (re)build & publish, e.g. v0.8.1" + required: true + type: string + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: release-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + # ---------------------------------------------------------------------- + # 1. Resolve tag/version + emit per-target matrix from npm/targets.json. + # ---------------------------------------------------------------------- + plan: + name: plan + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} + - id: resolve + shell: bash + run: | + set -euo pipefail + tag="${{ github.event.release.tag_name || inputs.tag }}" + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]] || { echo "::error::bad tag '$tag'"; exit 1; } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + - id: matrix + shell: bash + env: + ALLOW_EXPERIMENTAL: "false" + run: | + set -euo pipefail + # Drop experimental tier-3 targets unless explicitly enabled. + if [[ "$ALLOW_EXPERIMENTAL" == "true" ]]; then + filter='.targets' + else + filter='.targets | map(select(.experimental != true))' + fi + matrix=$(jq -c "{ include: ($filter) }" npm/targets.json) + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + # ---------------------------------------------------------------------- + # 2. Build a release tarball per target. Single matrix, branches on + # `build` strategy (cross | cargo | cargo-build-std | cargo-cross-toolchain). + # ---------------------------------------------------------------------- + build: + name: build ${{ matrix.rust }} + needs: plan + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + env: + ARCHIVE: runner-v${{ needs.plan.outputs.version }}-${{ matrix.rust }}.tar.gz + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.plan.outputs.tag }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.95" + targets: ${{ matrix.rust }} + components: ${{ matrix.build == 'cargo-build-std' && 'rust-src' || '' }} + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: release-${{ matrix.rust }} + + - name: Install cross + if: matrix.build == 'cross' + uses: taiki-e/install-action@v2 + with: { tool: cross } + + - name: Install NetBSD cross toolchain + if: matrix.build == 'cargo-cross-toolchain' + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + clang lld llvm libc6-dev + # Use pre-built netbsd sysroot from the Rust target maintainers. + curl -fsSL -o /tmp/netbsd-sysroot.tar.xz \ + https://github.com/rust-cross/netbsd-cross/releases/latest/download/x86_64-unknown-netbsd.tar.xz + sudo mkdir -p /opt/cross + sudo tar -C /opt/cross -xJf /tmp/netbsd-sysroot.tar.xz + echo "/opt/cross/bin" >> "$GITHUB_PATH" + + - name: cargo build (native) + if: matrix.build == 'cargo' + shell: bash + run: cargo build --release --locked --features run --target ${{ matrix.rust }} + + - name: cross build + if: matrix.build == 'cross' + shell: bash + run: cross build --release --locked --features run --target ${{ matrix.rust }} + + - name: cargo build -Z build-std + if: matrix.build == 'cargo-build-std' + shell: bash + run: | + rustup toolchain install nightly --component rust-src --profile minimal + cargo +nightly build --release --locked --features run \ + --target ${{ matrix.rust }} \ + -Z build-std=std,panic_abort \ + -Z build-std-features=panic_immediate_abort + + - name: cargo build (custom toolchain) + if: matrix.build == 'cargo-cross-toolchain' + shell: bash + env: + CARGO_TARGET_X86_64_UNKNOWN_NETBSD_LINKER: x86_64--netbsd-clang + CC_x86_64_unknown_netbsd: x86_64--netbsd-clang + run: cargo build --release --locked --features run --target ${{ matrix.rust }} + + - name: Package archive + id: pkg + shell: bash + run: | + set -euo pipefail + stage="$(mktemp -d)" + ext="" + if [[ "${{ runner.os }}" == "Windows" ]]; then ext=".exe"; fi + cp "target/${{ matrix.rust }}/release/runner${ext}" "$stage/" + cp "target/${{ matrix.rust }}/release/run${ext}" "$stage/" + cp README.md LICENSE "$stage/" + tar -C "$stage" -czf "$ARCHIVE" "runner${ext}" "run${ext}" README.md LICENSE + # sha256 + if command -v sha256sum >/dev/null; then + sha256sum "$ARCHIVE" > "${ARCHIVE%.tar.gz}.sha256" + else + shasum -a 256 "$ARCHIVE" > "${ARCHIVE%.tar.gz}.sha256" + fi + echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" + echo "sha256=${ARCHIVE%.tar.gz}.sha256" >> "$GITHUB_OUTPUT" + + - name: Attest build provenance + uses: actions/attest-build-provenance@v3 + with: + subject-path: ${{ steps.pkg.outputs.archive }} + + - name: Upload artifact + uses: actions/upload-artifact@v5 + with: + name: bin-${{ matrix.rust }} + path: | + ${{ steps.pkg.outputs.archive }} + ${{ steps.pkg.outputs.sha256 }} + if-no-files-found: error + retention-days: 7 + + # ---------------------------------------------------------------------- + # 3. Smoke-test the produced tarball on each native runner OS. + # ---------------------------------------------------------------------- + smoke: + name: smoke ${{ matrix.os }} + needs: [plan, build] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + triple: x86_64-unknown-linux-musl + - os: macos-latest + triple: aarch64-apple-darwin + - os: windows-latest + triple: x86_64-pc-windows-msvc + steps: + - uses: actions/download-artifact@v6 + with: + name: bin-${{ matrix.triple }} + path: dl + - name: Verify checksum + run --version + shell: bash + run: | + set -euo pipefail + cd dl + if command -v sha256sum >/dev/null; then + sha256sum -c "runner-v${{ needs.plan.outputs.version }}-${{ matrix.triple }}.sha256" + else + shasum -a 256 -c "runner-v${{ needs.plan.outputs.version }}-${{ matrix.triple }}.sha256" + fi + mkdir extracted + tar -C extracted -xzf "runner-v${{ needs.plan.outputs.version }}-${{ matrix.triple }}.tar.gz" + ext="" + [[ "${{ runner.os }}" == "Windows" ]] && ext=".exe" + ./extracted/runner${ext} --version | grep -F "${{ needs.plan.outputs.version }}" + ./extracted/run${ext} --version | grep -F "${{ needs.plan.outputs.version }}" + + # ---------------------------------------------------------------------- + # 4. Build npm subpackages + facade from the staged tarballs. + # ---------------------------------------------------------------------- + build_npm: + name: build npm packages + needs: [plan, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.plan.outputs.tag }} + + - uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + - uses: extractions/setup-just@v3 + - uses: actions/download-artifact@v6 + with: + pattern: bin-* + path: npm/downloads + merge-multiple: true + - name: List staged tarballs + run: ls -la npm/downloads + - name: Build all npm packages + run: just build-packages "" false "${{ needs.plan.outputs.version }}" + - name: Pack tarballs (one per subpackage + facade) + shell: bash + run: | + set -euo pipefail + mkdir -p npm/pack + for dir in npm/dist/*/; do + (cd "$dir" && npm pack --pack-destination "$GITHUB_WORKSPACE/npm/pack") + done + ls -la npm/pack + - name: Attest npm tarballs + uses: actions/attest-build-provenance@v3 + with: + subject-path: "npm/pack/*.tgz" + - uses: actions/upload-artifact@v5 + with: + name: npm-dist + path: npm/pack/*.tgz + if-no-files-found: error + retention-days: 7 + + # ---------------------------------------------------------------------- + # 5. Upload binaries + checksums to the (still draft) GitHub Release, + # then flip it to published. This is the single public-state pivot. + # ---------------------------------------------------------------------- + publish_release: + name: publish github release + needs: [plan, build, smoke, build_npm] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.plan.outputs.tag }} + + - uses: actions/download-artifact@v6 + with: + pattern: bin-* + path: dist + merge-multiple: true + - name: Upload assets to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cd dist + gh release upload "${{ needs.plan.outputs.tag }}" \ + --clobber --repo "${{ github.repository }}" \ + runner-v${{ needs.plan.outputs.version }}-*.tar.gz \ + runner-v${{ needs.plan.outputs.version }}-*.sha256 + - name: Mark release as published (latest) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release edit "${{ needs.plan.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --draft=false --latest + + # ---------------------------------------------------------------------- + # 6. Publish all npm tarballs (subpackages first, facade last). + # ---------------------------------------------------------------------- + publish_npm: + name: publish npm + needs: [plan, build_npm, publish_release] + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + env: + NPM_CONFIG_PROVENANCE: "true" + steps: + - uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + - uses: actions/download-artifact@v6 + with: + name: npm-dist + path: npm/pack + - name: Publish subpackages then facade + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + shopt -s nullglob + scoped=( npm/pack/runner-run-*.tgz ) # subpackages: @runner-run/* -> file name 'runner-run-*' + facade=( npm/pack/runner-run-${{ needs.plan.outputs.version }}.tgz ) + + # Distinguish the facade from subpkg tarballs by exact name match. + for t in "${scoped[@]}"; do + base=$(basename "$t") + if [[ "$base" == "runner-run-${{ needs.plan.outputs.version }}.tgz" ]]; then continue; fi + echo "::group::publish $base" + npm publish "$t" --access public --provenance + echo "::endgroup::" + done + for t in "${facade[@]}"; do + echo "::group::publish $(basename "$t") (facade)" + npm publish "$t" --access public --provenance + echo "::endgroup::" + done + + # ---------------------------------------------------------------------- + # 7. Out-of-band verification that the published artifacts actually + # install and report the right version on Linux. + # ---------------------------------------------------------------------- + verify_published: + name: verify (${{ matrix.path }}) + needs: [plan, publish_release, publish_npm] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + path: [npm, install-sh, binstall] + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.plan.outputs.tag }} + + - if: matrix.path == 'npm' + uses: actions/setup-node@v6 + with: { node-version: "24" } + - name: verify via npm + if: matrix.path == 'npm' + run: | + set -euo pipefail + npm install -g "runner-run@${{ needs.plan.outputs.version }}" + runner --version | grep -F "${{ needs.plan.outputs.version }}" + run --version | grep -F "${{ needs.plan.outputs.version }}" + - name: verify via install.sh + if: matrix.path == 'install-sh' + run: | + set -euo pipefail + RUNNER_INSTALL_DIR="$HOME/.local/bin" \ + bash install.sh "v${{ needs.plan.outputs.version }}" + "$HOME/.local/bin/runner" --version | grep -F "${{ needs.plan.outputs.version }}" + "$HOME/.local/bin/run" --version | grep -F "${{ needs.plan.outputs.version }}" + - name: verify via cargo binstall + if: matrix.path == 'binstall' + uses: cargo-bins/cargo-binstall@main + - if: matrix.path == 'binstall' + run: | + set -euo pipefail + cargo binstall --no-confirm --force "runner-run@${{ needs.plan.outputs.version }}" + runner --version | grep -F "${{ needs.plan.outputs.version }}" + run --version | grep -F "${{ needs.plan.outputs.version }}" diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..f6d043c --- /dev/null +++ b/cliff.toml @@ -0,0 +1,65 @@ +# git-cliff config used by release-plz to render Keep-a-Changelog +# sections. Mirrors the existing CHANGELOG.md style (Added/Changed/ +# Fixed/Removed/Security buckets, ISO date, version compare links). + +[changelog] +header = """ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog], and this project adheres to [Semantic Versioning]. + +[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/ +[Semantic Versioning]: https://semver.org/spec/v2.0.0.html +""" + +body = """ +{% if version %}\ +## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | upper_first }} + +{% for commit in commits %}\ +- {{ commit.message | upper_first | trim }}\ +{% if commit.breaking %} **(breaking)**{% endif %}\ +{% if commit.id %} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/kjanat/runner/commit/{{ commit.id }})){% endif %} +{% endfor %}\ +{% endfor %}\n +""" + +footer = "" +trim = true +postprocessors = [] + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +protect_breaking_commits = true +filter_commits = true +tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+" +topo_order = false +sort_commits = "newest" + +commit_parsers = [ + { message = "^feat", group = "Added" }, + { message = "^add", group = "Added" }, + { message = "^change", group = "Changed" }, + { message = "^refactor", group = "Changed" }, + { message = "^perf", group = "Changed" }, + { message = "^style", group = "Changed" }, + { message = "^deprecate", group = "Deprecated" }, + { message = "^remove", group = "Removed" }, + { message = "^fix", group = "Fixed" }, + { message = "^bug", group = "Fixed" }, + { message = "^security", group = "Security" }, + { message = "^deps", group = "Changed" }, + { message = "^build", group = "Changed" }, + { message = "^revert", group = "Changed" }, + { message = "^(chore|ci|docs|test)", skip = true }, + { body = ".*security", group = "Security" }, +] diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 0000000..9fbadd8 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,39 @@ +[workspace] +# The crate ships as `runner-run` on crates.io; tags use the `v` prefix. +git_tag_name = "v{{ version }}" +git_tag_enable = true + +# Create the GitHub release as a draft. release.yml attaches binary +# tarballs + npm provenance, then flips it to published. +git_release_enable = true +git_release_draft = true +git_release_type = "auto" +git_release_latest = true + +# Use conventional commits to drive the version bump. +semver_check = true +publish = true # cargo publish to crates.io +publish_allow_dirty = false +publish_features = [] # publish with default features +publish_no_verify = false +publish_timeout = "30m" + +# Changelog updates land in the PR. +changelog_update = true +changelog_path = "CHANGELOG.md" +changelog_config = "cliff.toml" + +# Release-plz prepends new sections; keep the "[Unreleased]" header + +# post-release checklist intact. +changelog_include = [] +release_commits = "^(feat|fix|perf|refactor|deps|build|revert)(\\(.*\\))?(!)?:" + +# PR settings +pr_branch_prefix = "release-plz/" +pr_draft = false +pr_labels = ["release"] +pr_name = "release: prepare v{{ version }}" + +[[package]] +name = "runner-run" +changelog_path = "CHANGELOG.md"