diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 09a3060..af9452e 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -19,30 +19,30 @@ on: - compact-builder - compact-cli - compact-deployer + - compact-linter - compact-simulator jobs: - publish: - name: Publish merged release + # Detection is its own job so the binary matrix and the publish both read the + # same answer. + detect: + name: Detect released package if: >- github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')) runs-on: ubuntu-24.04 - environment: compact-npm-prod # Final approval gate before npm publish permissions: - contents: write # push the version tag - id-token: write # mint the npm OIDC credential and the provenance statement + contents: read - steps: - - name: Get github app token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - id: gh-app-token - with: - app-id: ${{ vars.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + outputs: + dir: ${{ steps.pkg.outputs.dir }} + name: ${{ steps.pkg.outputs.name }} + version: ${{ steps.pkg.outputs.version }} + dist_tag: ${{ steps.pkg.outputs.dist_tag }} + steps: # For pull_request runs: checkout the merge commit (deterministic view of # what was just merged). For manual dispatch: checkout the dispatched ref # (defaults to main but can be any branch — hotfix, release-prep, etc.). @@ -53,7 +53,6 @@ jobs: with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }} - token: ${{ steps.gh-app-token.outputs.token }} - name: Detect released package id: pkg @@ -69,6 +68,7 @@ jobs: compact-builder) DIR="builder" ;; compact-cli) DIR="cli" ;; compact-deployer) DIR="deployer" ;; + compact-linter) DIR="linter" ;; compact-simulator) DIR="simulator" ;; *) echo "::error::unknown package: $PKG" @@ -88,6 +88,7 @@ jobs: builder) PKG="compact-builder" ;; cli) PKG="compact-cli" ;; deployer) PKG="compact-deployer" ;; + linter) PKG="compact-linter" ;; simulator) PKG="compact-simulator" ;; *) echo "::error::unknown package directory: $DIR" @@ -135,6 +136,98 @@ jobs: echo "- Trigger: $EVENT" } >> $GITHUB_STEP_SUMMARY + # compact-linter ships a native binary per target, built on its own runner. The + # npm package downloads them, so they must reach the GitHub release first. + binaries: + name: Build compact-lint for ${{ matrix.target }} + needs: detect + if: needs.detect.outputs.name == 'compact-linter' + runs-on: ${{ matrix.runner }} + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + - target: x86_64-apple-darwin + runner: macos-15-intel + - target: aarch64-apple-darwin + runner: macos-15 + + steps: + - name: Harden Runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Check out target ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }} + + - name: Install the pinned toolchain + run: | + CHANNEL=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2) + rustup toolchain install "$CHANNEL" --profile minimal + rustup target add ${{ matrix.target }} + rustup show active-toolchain + + - name: Build + run: cargo build --release -p compact-lint --target ${{ matrix.target }} + + # Every target is built on its own native runner, so the binary runs here. + - name: Smoke test + run: target/${{ matrix.target }}/release/compact-lint --version + + - name: Stage the release asset + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/compact-lint dist/compact-lint-${{ matrix.target }} + + - name: Upload the release asset + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: compact-lint-${{ matrix.target }} + path: dist/compact-lint-${{ matrix.target }} + if-no-files-found: error + + publish: + name: Publish merged release + needs: [detect, binaries] + # `binaries` is skipped for every package but compact-linter, and a skipped + # dependency must not skip the publish. + if: >- + always() && + needs.detect.result == 'success' && + (needs.binaries.result == 'success' || needs.binaries.result == 'skipped') + runs-on: ubuntu-24.04 + environment: compact-npm-prod # Final approval gate before npm publish + + permissions: + contents: write # push the version tag and create the GitHub release + id-token: write # mint the npm OIDC credential and the provenance statement + + steps: + - name: Get github app token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: gh-app-token + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Check out target ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }} + token: ${{ steps.gh-app-token.outputs.token }} + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -148,11 +241,11 @@ jobs: run: yarn install --immutable - name: Build package - run: yarn build --filter=@openzeppelin/${{ steps.pkg.outputs.name }} + run: yarn build --filter=@openzeppelin/${{ needs.detect.outputs.name }} - name: Create and push tag env: - TAG: ${{ steps.pkg.outputs.name }}/v${{ steps.pkg.outputs.version }} + TAG: ${{ needs.detect.outputs.name }}/v${{ needs.detect.outputs.version }} run: | if git ls-remote --tags --exit-code origin "refs/tags/$TAG" >/dev/null; then echo "tag $TAG already on origin, leaving it in place" @@ -161,7 +254,55 @@ jobs: git push origin "$TAG" fi + - name: Download the release binaries + if: needs.detect.outputs.name == 'compact-linter' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: dist + pattern: compact-lint-* + merge-multiple: true + + # Asset names only: the installed package looks each digest up by the name + # it downloads. + - name: Write the checksums + if: needs.detect.outputs.name == 'compact-linter' + working-directory: dist + run: | + sha256sum compact-lint-* > checksums.txt + cat checksums.txt + + # The npm package downloads these assets on first run, so the release has + # to carry them before the package reaches npm. + - name: Create the GitHub release + if: needs.detect.outputs.name == 'compact-linter' + env: + GH_TOKEN: ${{ steps.gh-app-token.outputs.token }} + TAG: ${{ needs.detect.outputs.name }}/v${{ needs.detect.outputs.version }} + VERSION: ${{ needs.detect.outputs.version }} + DIST_TAG: ${{ needs.detect.outputs.dist_tag }} + run: | + NOTES=$(awk -v v="$VERSION" ' + $0 ~ "^## " v "( |$)" { found = 1; next } + found && /^## / { exit } + found { print } + ' packages/linter/CHANGELOG.md) + if [[ -z "${NOTES//[[:space:]]/}" ]]; then + NOTES="$TAG" + fi + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/* --clobber + else + ARGS=() + if [[ "$DIST_TAG" == "beta" ]]; then + ARGS+=(--prerelease) + fi + gh release create "$TAG" dist/* \ + --title "compact-linter v$VERSION" \ + --notes "$NOTES" \ + "${ARGS[@]}" + fi + - name: Publish to npm run: | - cd packages/${{ steps.pkg.outputs.dir }} - yarn npm publish --access public --provenance --tag ${{ steps.pkg.outputs.dist_tag }} + cd packages/${{ needs.detect.outputs.dir }} + yarn npm publish --access public --provenance --tag ${{ needs.detect.outputs.dist_tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ca6340..b9522bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,7 @@ on: - compact-builder - compact-cli - compact-deployer + - compact-linter - compact-simulator version_bump: description: "Version bump type (pre* strategies are beta-only)" @@ -89,6 +90,9 @@ jobs: "compact-deployer") echo "dir=deployer" >> $GITHUB_OUTPUT ;; + "compact-linter") + echo "dir=linter" >> $GITHUB_OUTPUT + ;; "compact-simulator") echo "dir=simulator" >> $GITHUB_OUTPUT ;; @@ -97,6 +101,15 @@ jobs: - name: Setup Environment uses: ./.github/actions/setup + # compact-linter's version lives in the crate too, and bumping it there + # rewrites Cargo.lock. + - name: Install the pinned toolchain + if: inputs.package == 'compact-linter' + run: | + CHANNEL=$(grep -m1 '^channel' rust-toolchain.toml | cut -d'"' -f2) + rustup toolchain install "$CHANNEL" --profile minimal + rustup show active-toolchain + - name: Run tests for package run: yarn test --filter=@openzeppelin/${{ inputs.package }} @@ -134,6 +147,20 @@ jobs: echo "- npm dist-tag: $DIST_TAG" } >> $GITHUB_STEP_SUMMARY + - name: Sync the crate version + if: inputs.package == 'compact-linter' + env: + NEW_VERSION: ${{ steps.version.outputs.new }} + run: | + sed -i "0,/^version = .*/s//version = \"$NEW_VERSION\"/" crates/compact-lint/Cargo.toml + # Resolving the workspace rewrites the crate's own Cargo.lock entry. + cargo metadata --format-version 1 > /tmp/cargo-metadata.json + ACTUAL=$(node -p "JSON.parse(require('fs').readFileSync('/tmp/cargo-metadata.json','utf8')).packages.find((p) => p.name === 'compact-lint').version") + if [[ "$ACTUAL" != "$NEW_VERSION" ]]; then + echo "::error::crates/compact-lint/Cargo.toml is at $ACTUAL, expected $NEW_VERSION" + exit 1 + fi + - name: Verify package contents run: | cd packages/${{ steps.pkg.outputs.dir }} @@ -156,6 +183,7 @@ jobs: fi - name: Commit version bump + if: inputs.package != 'compact-linter' uses: iarekylew00t/verified-bot-commit@42a042b0407248f3c0acac00c389e729e3476e86 # v2.3.5 with: message: "release: ${{ inputs.package }} v${{ steps.version.outputs.new }}" @@ -164,6 +192,18 @@ jobs: files: | packages/${{ steps.pkg.outputs.dir }}/package.json + - name: Commit version bump with the crate + if: inputs.package == 'compact-linter' + uses: iarekylew00t/verified-bot-commit@42a042b0407248f3c0acac00c389e729e3476e86 # v2.3.5 + with: + message: "release: ${{ inputs.package }} v${{ steps.version.outputs.new }}" + token: ${{ steps.gh-app-token.outputs.token }} + ref: ${{ steps.version.outputs.branch }} + files: | + packages/linter/package.json + crates/compact-lint/Cargo.toml + Cargo.lock + - name: Ensure release label exists env: GH_TOKEN: ${{ steps.gh-app-token.outputs.token }} diff --git a/.gitignore b/.gitignore index a66d7c3..48f64fe 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ vendor/ target/ .toolkit-cache/ +# compact-lint's release binary, cached inside the installed npm package. +packages/linter/.cache/ + coverage **/reports diff --git a/Cargo.toml b/Cargo.toml index 3e63a37..a235a01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,13 @@ license = "MIT" repository = "https://github.com/OpenZeppelin/compact-tools" rust-version = "1.96" +# Release binaries ship as GitHub release assets, so size and speed both matter. +# Unwinding stays on: the error paths depend on it. +[profile.release] +strip = true +lto = "thin" +codegen-units = 1 + [workspace.lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index 4bbe406..5d39deb 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Tools for compiling, building, testing, and deploying Compact smart contracts. T - [`packages/builder`](./packages/builder) — programmatic library that drives the Compact compiler + builder - [`packages/cli`](./packages/cli) — thin bin wrapper around the builder and deployer libraries (`compact-compiler`, `compact-builder`, `compact-deploy`) - [`packages/deployer`](./packages/deployer) — deployer library that submits a compiled contract to a Midnight network. Pins one Midnight stack: see [Supported stack](./packages/deployer/README.md#supported-stack) +- [`packages/linter`](./packages/linter) — bin wrapper around the [`compact-lint`](./crates/compact-lint) crate, the doc-comment linter for Compact sources - [`packages/simulator`](./packages/simulator) — TypeScript simulator to run and test Compact contracts locally See each package's README for usage, options, and examples. @@ -38,6 +39,9 @@ yarn add --dev @openzeppelin/compact-cli # Simulator — test Compact contracts locally yarn add --dev @openzeppelin/compact-simulator +# Linter — check doc comments in .compact sources +yarn add --dev @openzeppelin/compact-linter + # Deployer — deploy a compiled contract from TypeScript yarn add --dev @openzeppelin/compact-deployer ``` @@ -59,7 +63,7 @@ import { createSimulator } from '@openzeppelin/compact-simulator'; ## Requirements -- Node.js >= 22 for `packages/builder` and `packages/simulator`, >= 24 for `packages/cli` and `packages/deployer` +- Node.js >= 22 for `packages/builder` and `packages/simulator`, >= 24 for `packages/cli`, `packages/deployer` and `packages/linter` - Yarn 4 (Berry) - Turbo - Optional: Midnight Compact toolchain installed and available in `PATH` diff --git a/RELEASING.md b/RELEASING.md index a8ddbbe..fd3494e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -49,7 +49,8 @@ release notes agree. - Create a git tag. - Publish the package to npm under the channel's dist-tag. 7. Once published, go to "Releases" and create a GitHub release using the - generated tag. Mark beta tags as prereleases. + generated tag. Mark beta tags as prereleases. `compact-linter` is the + exception: the workflow creates its release itself (see below). ## Graduating a beta to stable @@ -80,7 +81,7 @@ breaking change. After that, only `prerelease` moves the counter. Running ## First-release order -There's a one-step dependency chain across the four published packages: +There's a one-step dependency chain across the five published packages: ```text compact-cli (bin wrapper) @@ -88,6 +89,7 @@ compact-cli (bin wrapper) └─ depends on compact-deployer compact-builder (library) compact-deployer (library) +compact-linter (bin wrapper around the compact-lint crate) compact-simulator (library) ``` @@ -98,8 +100,38 @@ each dependent finds its deps already on npm: 1. `compact-builder` (no internal deps) 2. `compact-simulator` (no internal deps) 3. `compact-deployer` (no internal deps) -4. `compact-cli` (depends on `compact-builder` and `compact-deployer`; pull +4. `compact-linter` (no internal deps) +5. `compact-cli` (depends on `compact-builder` and `compact-deployer`; pull `main` first so both bump commits are present locally before triggering) -After the first release, the four packages version independently — bump any +After the first release, the five packages version independently — bump any one of them in isolation without re-publishing the others. + +## compact-linter + +`compact-linter` is a bin wrapper around the `compact-lint` crate, so its release +carries a native binary as well as an npm package. + +- The version lives in two files: `packages/linter/package.json` and + `crates/compact-lint/Cargo.toml`. The release workflow bumps the package, mirrors + the value into the crate, refreshes `Cargo.lock`, and commits all three. A unit + test in `packages/linter` fails when the two drift. +- The publish workflow builds four binaries before publishing, one per target, each + on its own runner: + + | Target | Runner | + | --- | --- | + | `x86_64-unknown-linux-gnu` | `ubuntu-24.04` | + | `aarch64-unknown-linux-gnu` | `ubuntu-24.04-arm` | + | `x86_64-apple-darwin` | `macos-15-intel` | + | `aarch64-apple-darwin` | `macos-15` | + +- It then creates the GitHub release `compact-linter/v` with the four assets + and a `checksums.txt`, and only then publishes to npm. The installed package + downloads its binary from that release, so the assets must be there first. For + every other package the GitHub release is still created by hand (step 7 above). +- Release notes come from the `## ` section of `packages/linter/CHANGELOG.md`, + falling back to the tag name. A `beta` dist-tag marks the release a prerelease. +- Before the first release, add `@openzeppelin/compact-linter` as a Trusted Publisher + on npmjs.org, naming this repo and `.github/workflows/release-publish.yml`. Every + new npm package needs this once, or the publish step cannot authenticate. diff --git a/crates/compact-lint/README.md b/crates/compact-lint/README.md index 9deeb0b..b99e898 100644 --- a/crates/compact-lint/README.md +++ b/crates/compact-lint/README.md @@ -6,6 +6,13 @@ per-kind template in `compact-lint.toml`, and prints one finding per line. `fix` rewrites the comments the repairable rules report. `fill-constraints` compiles the contracts and writes the measured `k` and `rows` into the annotations. +## Install + +- npm, no Rust toolchain needed: `yarn add -D @openzeppelin/compact-linter`, or + `npx @openzeppelin/compact-linter check`. The package downloads the release binary for + the host on first use. See [`packages/linter`](../../packages/linter). +- From source: `cargo install --path crates/compact-lint`. + ## Usage ```sh diff --git a/packages/linter/CHANGELOG.md b/packages/linter/CHANGELOG.md new file mode 100644 index 0000000..5d88638 --- /dev/null +++ b/packages/linter/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to `@openzeppelin/compact-linter` are documented in this +file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Added + +- `compact-linter` bin, a launcher that downloads the [`compact-lint`](../../crates/compact-lint) release binary for the host, verifies its SHA-256, and runs it diff --git a/packages/linter/LICENSE b/packages/linter/LICENSE new file mode 100644 index 0000000..e22ac90 --- /dev/null +++ b/packages/linter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Zeppelin Group Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/linter/README.md b/packages/linter/README.md new file mode 100644 index 0000000..6bf577b --- /dev/null +++ b/packages/linter/README.md @@ -0,0 +1,45 @@ +# @openzeppelin/compact-linter + +Bin wrapper around the [`compact-lint`](../../crates/compact-lint) Rust crate, the +doc-comment linter for Compact sources. The package itself is a thin launcher: it +resolves the native binary for the host and runs it with your arguments. + +## Install + +```bash +yarn add --dev @openzeppelin/compact-linter +``` + +```bash +npx @openzeppelin/compact-linter check +yarn compact-linter check contracts/src +yarn compact-linter fix --dry-run +``` + +Commands, flags, exit codes and `compact-lint.toml` are documented in the +[crate README](../../crates/compact-lint/README.md). + +## Where the binary comes from + +- Prebuilt binaries are attached to the `compact-linter/v` GitHub release, one + per target: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, + `x86_64-apple-darwin`, `aarch64-apple-darwin`. +- The first run downloads the one matching the host, checks its SHA-256 against the + release's `checksums.txt`, and caches it at `.cache/compact-lint--` + inside the installed package. Later runs print nothing and start it directly. +- Uninstalling the package removes the cache with it. +- No prebuilt binary for the host is a hard error: build the crate with + `cargo install --path crates/compact-lint` and point `COMPACT_LINT_BINARY` at it. + +## Environment + +| Variable | Effect | +| --- | --- | +| `COMPACT_LINT_BINARY` | Run this executable and skip the download. For local crate builds and air-gapped CI. | +| `COMPACT_LINT_DOWNLOAD_BASE` | Fetch the asset and `checksums.txt` from this base URL instead of the GitHub release. | +| `COMPACT_LINT_COMPACT_BIN` | Read by the linter itself: path to the `compact` binary. | + +## Requirements + +- Node.js >= 24 +- Linux or macOS, x86_64 or aarch64 diff --git a/packages/linter/package.json b/packages/linter/package.json new file mode 100644 index 0000000..81cdf57 --- /dev/null +++ b/packages/linter/package.json @@ -0,0 +1,43 @@ +{ + "name": "@openzeppelin/compact-linter", + "description": "Doc-comment linter for Compact sources", + "version": "0.1.0", + "keywords": [ + "compact", + "lint", + "linter", + "doc-comments", + "midnight" + ], + "author": "OpenZeppelin Community ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/OpenZeppelin/compact-tools.git", + "directory": "packages/linter" + }, + "type": "module", + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], + "engines": { + "node": ">=24" + }, + "bin": "dist/compactLinter.js", + "scripts": { + "build": "tsc -p .", + "types": "tsc -p tsconfig.json --noEmit", + "test": "yarn vitest run", + "coverage": "yarn vitest run --coverage", + "clean": "git clean -fXd" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.3", + "@types/node": "26.1.2", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/packages/linter/src/binary.ts b/packages/linter/src/binary.ts new file mode 100644 index 0000000..8c73bd4 --- /dev/null +++ b/packages/linter/src/binary.ts @@ -0,0 +1,143 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; + +/** npm `platform-arch` pair to the Rust target triple the release assets are named after. */ +const TARGETS: Record = { + 'linux-x64': 'x86_64-unknown-linux-gnu', + 'linux-arm64': 'aarch64-unknown-linux-gnu', + 'darwin-x64': 'x86_64-apple-darwin', + 'darwin-arm64': 'aarch64-apple-darwin', +}; + +const RELEASE_BASE = + 'https://github.com/OpenZeppelin/compact-tools/releases/download'; + +/** A stalled release download fails instead of hanging the lint run. */ +const DOWNLOAD_TIMEOUT_MS = 120_000; + +/** Where the binary is fetched from and cached, and who is told about a download. */ +export interface ResolveOptions { + /** Root of the installed package; the cache lives under `.cache/` inside it. */ + packageDir: string; + version: string; + platform: string; + arch: string; + env: Record; + /** Called once, before the first download of a run. */ + notify: (message: string) => void; +} + +export function resolveTarget(platform: string, arch: string): string | null { + return TARGETS[`${platform}-${arch}`] ?? null; +} + +export function assetName(target: string): string { + return `compact-lint-${target}`; +} + +export function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** ` ` lines to an asset-keyed map; CRLF and the `*` binary marker are tolerated. */ +export function parseChecksums(text: string): Map { + const digests = new Map(); + for (const line of text.split('\n')) { + const entry = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(line.trim()); + if (entry) { + digests.set(entry[2], entry[1]); + } + } + return digests; +} + +export function expectedChecksum(text: string, asset: string): string { + const digest = parseChecksums(text).get(asset); + if (digest === undefined) { + throw new Error(`checksums.txt has no entry for ${asset}`); + } + return digest; +} + +/** + * Path to the `compact-lint` executable, downloading it into the package cache when missing. + * + * Messages carry no `compact-lint:` prefix; the bin adds it. + */ +export async function resolveBinary(options: ResolveOptions): Promise { + const override = options.env.COMPACT_LINT_BINARY; + if (override) { + return override; + } + + const target = resolveTarget(options.platform, options.arch); + if (target === null) { + throw new Error( + `no prebuilt binary for ${options.platform}-${options.arch}; build from source with cargo (crates/compact-lint) and set COMPACT_LINT_BINARY`, + ); + } + + const cacheDir = join(options.packageDir, '.cache'); + const cached = join(cacheDir, `compact-lint-${options.version}-${target}`); + if (existsSync(cached)) { + return cached; + } + + const asset = assetName(target); + const base = + options.env.COMPACT_LINT_DOWNLOAD_BASE ?? + `${RELEASE_BASE}/compact-linter/v${options.version}`; + options.notify(`downloading ${asset} (v${options.version})`); + + const [binary, checksums] = await Promise.all([ + fetchBytes(`${base}/${asset}`), + fetchText(`${base}/checksums.txt`), + ]); + + const expected = expectedChecksum(checksums, asset); + const actual = sha256(binary); + if (actual !== expected) { + throw new Error( + `checksum mismatch for ${asset}: expected ${expected}, got ${actual}; delete ${cached} and retry`, + ); + } + + // Staged under a unique name so concurrent runs never read a half-written file. + mkdirSync(cacheDir, { recursive: true }); + const staged = `${cached}.${randomUUID()}.tmp`; + writeFileSync(staged, binary); + chmodSync(staged, 0o755); + renameSync(staged, cached); + return cached; +} + +async function fetchOk(url: string): Promise { + let response: Response; + try { + response = await fetch(url, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + } catch (cause) { + const reason = cause instanceof Error ? cause.message : String(cause); + throw new Error(`download failed for ${url}: ${reason}`); + } + if (!response.ok) { + throw new Error(`download failed for ${url}: HTTP ${response.status}`); + } + return response; +} + +async function fetchBytes(url: string): Promise { + return new Uint8Array(await (await fetchOk(url)).arrayBuffer()); +} + +async function fetchText(url: string): Promise { + return (await fetchOk(url)).text(); +} diff --git a/packages/linter/src/compactLinter.ts b/packages/linter/src/compactLinter.ts new file mode 100644 index 0000000..3300348 --- /dev/null +++ b/packages/linter/src/compactLinter.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { constants } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveBinary } from './binary.ts'; + +// One level up from src/ and from dist/ alike, so both entry points find the manifest. +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); + +function packageVersion(): string { + const manifest = readFileSync(join(packageDir, 'package.json'), 'utf8'); + return (JSON.parse(manifest) as { version: string }).version; +} + +function fail(message: string): never { + process.stderr.write(`compact-linter: ${message} +`); + process.exit(2); +} + +async function runLint(): Promise { + let binary: string; + try { + binary = await resolveBinary({ + packageDir, + version: packageVersion(), + platform: process.platform, + arch: process.arch, + env: process.env, + notify: (message) => + process.stderr.write(`compact-linter: ${message} +`), + }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + + // argv0 keeps the cache file name out of the linter's own usage output. + const result = spawnSync(binary, process.argv.slice(2), { + argv0: 'compact-linter', + stdio: 'inherit', + }); + if (result.error) { + fail(`cannot run ${binary}: ${result.error.message}`); + } + if (result.signal) { + process.exit(128 + (constants.signals[result.signal] ?? 0)); + } + process.exit(result.status ?? 0); +} + +await runLint(); diff --git a/packages/linter/test/binary.test.ts b/packages/linter/test/binary.test.ts new file mode 100644 index 0000000..18de25c --- /dev/null +++ b/packages/linter/test/binary.test.ts @@ -0,0 +1,293 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + expectedChecksum, + parseChecksums, + resolveBinary, + resolveTarget, + sha256, +} from '../src/binary.ts'; + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); +const FAKE_BINARY = '#!/bin/sh\necho fake-lint "$@"\n'; +const ASSET = 'compact-lint-x86_64-unknown-linux-gnu'; + +interface Origin { + base: string; + requests: string[]; + close: () => Promise; +} + +/** A release-shaped origin: one asset, one `checksums.txt`, every request path recorded. */ +async function startOrigin(files: Record): Promise { + const requests: string[] = []; + const server: Server = createServer((request, response) => { + const path = (request.url ?? '/').slice(1); + requests.push(path); + const body = files[path]; + if (body === undefined) { + response.writeHead(404).end('not found'); + return; + } + response.writeHead(200).end(body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { + base: `http://127.0.0.1:${port}`, + requests, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} + +function checksumsFor(asset: string, body: string): string { + const digest = createHash('sha256').update(body).digest('hex'); + return [ + `${'0'.repeat(64)} compact-lint-aarch64-apple-darwin`, + `${digest} ${asset}`, + `${'1'.repeat(64)} compact-lint-aarch64-unknown-linux-gnu`, + ].join('\n'); +} + +let install: string; +let notices: string[]; + +function options( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + packageDir: install, + version: '0.1.0', + platform: 'linux', + arch: 'x64', + env: {}, + notify: (message) => notices.push(message), + ...overrides, + }; +} + +beforeEach(() => { + install = mkdtempSync(join(tmpdir(), 'compact-lint-pkg-')); + notices = []; +}); + +afterEach(() => { + rmSync(install, { recursive: true, force: true }); +}); + +describe('resolveTarget', () => { + it('maps every supported platform and architecture to its rust target', () => { + expect(resolveTarget('linux', 'x64')).toBe('x86_64-unknown-linux-gnu'); + expect(resolveTarget('linux', 'arm64')).toBe('aarch64-unknown-linux-gnu'); + expect(resolveTarget('darwin', 'x64')).toBe('x86_64-apple-darwin'); + expect(resolveTarget('darwin', 'arm64')).toBe('aarch64-apple-darwin'); + }); + + it('returns null for a platform with no release asset', () => { + expect(resolveTarget('win32', 'x64')).toBeNull(); + expect(resolveTarget('linux', 'ia32')).toBeNull(); + }); +}); + +describe('parseChecksums', () => { + it('reads every entry of a multi-asset file', () => { + const digests = parseChecksums(checksumsFor(ASSET, FAKE_BINARY)); + expect(digests.size).toBe(3); + expect(digests.get(ASSET)).toBe(sha256(Buffer.from(FAKE_BINARY))); + }); + + it('reads entries written with CRLF line endings', () => { + const digests = parseChecksums( + checksumsFor(ASSET, FAKE_BINARY).replaceAll('\n', '\r\n'), + ); + expect(digests.get(ASSET)).toBe(sha256(Buffer.from(FAKE_BINARY))); + }); + + it('reads the entry of a file marked binary with an asterisk', () => { + const digests = parseChecksums(`${'a'.repeat(64)} *${ASSET}`); + expect(digests.get(ASSET)).toBe('a'.repeat(64)); + }); + + it('ignores blank lines and lines that hold no digest', () => { + const digests = parseChecksums('\n# a comment\nnot a checksum line\n'); + expect(digests.size).toBe(0); + }); + + it('rejects a file with no entry for the asset', () => { + expect(() => expectedChecksum('', ASSET)).toThrow( + `checksums.txt has no entry for ${ASSET}`, + ); + }); +}); + +describe('resolveBinary', () => { + it('reports a platform with no release asset', async () => { + await expect( + resolveBinary(options({ platform: 'win32', arch: 'x64' })), + ).rejects.toThrow( + 'no prebuilt binary for win32-x64; build from source with cargo (crates/compact-lint) and set COMPACT_LINT_BINARY', + ); + }); + + it('returns COMPACT_LINT_BINARY without contacting the origin', async () => { + const origin = await startOrigin({}); + try { + const binary = await resolveBinary( + options({ + env: { + COMPACT_LINT_BINARY: '/opt/compact-lint', + COMPACT_LINT_DOWNLOAD_BASE: origin.base, + }, + }), + ); + expect(binary).toBe('/opt/compact-lint'); + expect(origin.requests).toStrictEqual([]); + expect(notices).toStrictEqual([]); + } finally { + await origin.close(); + } + }); + + it('downloads, verifies and caches the release asset', async () => { + const origin = await startOrigin({ + [ASSET]: FAKE_BINARY, + 'checksums.txt': checksumsFor(ASSET, FAKE_BINARY), + }); + try { + const binary = await resolveBinary( + options({ env: { COMPACT_LINT_DOWNLOAD_BASE: origin.base } }), + ); + expect(binary).toBe( + join(install, '.cache', 'compact-lint-0.1.0-x86_64-unknown-linux-gnu'), + ); + expect(statSync(binary).mode & 0o777).toBe(0o755); + expect(spawnSync(binary, ['check'], { encoding: 'utf8' }).stdout).toBe( + 'fake-lint check\n', + ); + expect(origin.requests.toSorted()).toStrictEqual([ + 'checksums.txt', + ASSET, + ]); + expect(notices).toStrictEqual([ + 'downloading compact-lint-x86_64-unknown-linux-gnu (v0.1.0)', + ]); + } finally { + await origin.close(); + } + }); + + it('serves a second run from the cache', async () => { + const origin = await startOrigin({ + [ASSET]: FAKE_BINARY, + 'checksums.txt': checksumsFor(ASSET, FAKE_BINARY), + }); + try { + const first = await resolveBinary( + options({ env: { COMPACT_LINT_DOWNLOAD_BASE: origin.base } }), + ); + const second = await resolveBinary( + options({ env: { COMPACT_LINT_DOWNLOAD_BASE: origin.base } }), + ); + expect(second).toBe(first); + expect(origin.requests).toHaveLength(2); + expect(notices).toHaveLength(1); + } finally { + await origin.close(); + } + }); + + it('rejects an asset whose digest does not match checksums.txt', async () => { + const origin = await startOrigin({ + [ASSET]: FAKE_BINARY, + 'checksums.txt': checksumsFor(ASSET, 'a different binary'), + }); + try { + const expected = sha256(Buffer.from('a different binary')); + const actual = sha256(Buffer.from(FAKE_BINARY)); + await expect( + resolveBinary( + options({ env: { COMPACT_LINT_DOWNLOAD_BASE: origin.base } }), + ), + ).rejects.toThrow( + `checksum mismatch for ${ASSET}: expected ${expected}, got ${actual}; delete ${join(install, '.cache', 'compact-lint-0.1.0-x86_64-unknown-linux-gnu')} and retry`, + ); + } finally { + await origin.close(); + } + }); + + it('rejects an origin that answers with an http error', async () => { + const origin = await startOrigin({}); + try { + await expect( + resolveBinary( + options({ env: { COMPACT_LINT_DOWNLOAD_BASE: origin.base } }), + ), + ).rejects.toThrow( + `download failed for ${origin.base}/${ASSET}: HTTP 404`, + ); + } finally { + await origin.close(); + } + }); + + it('rejects an origin that cannot be reached', async () => { + await expect( + resolveBinary( + options({ + env: { COMPACT_LINT_DOWNLOAD_BASE: 'http://127.0.0.1:1/download' }, + }), + ), + ).rejects.toThrow('download failed for http://127.0.0.1:1/download/'); + }); +}); + +describe('the compact-linter bin', () => { + it('passes its arguments to the binary and forwards the exit code', () => { + const stub = join(install, 'stub-lint'); + writeFileSync(stub, '#!/bin/sh\necho "args: $*"\nexit 7\n'); + chmodSync(stub, 0o755); + + const run = spawnSync( + process.execPath, + [join(packageDir, 'src', 'compactLinter.ts'), 'check', '--strict', 'src'], + { encoding: 'utf8', env: { ...process.env, COMPACT_LINT_BINARY: stub } }, + ); + + expect(run.stdout).toBe('args: check --strict src\n'); + expect(run.status).toBe(7); + }); + + it('exits 2 when no binary can be resolved', () => { + const run = spawnSync( + process.execPath, + [join(packageDir, 'src', 'compactLinter.ts'), 'check'], + { + encoding: 'utf8', + env: { + ...process.env, + COMPACT_LINT_BINARY: join(install, 'not-installed'), + }, + }, + ); + + expect(run.status).toBe(2); + expect(run.stderr).toContain('compact-linter: cannot run'); + }); +}); diff --git a/packages/linter/test/version.test.ts b/packages/linter/test/version.test.ts new file mode 100644 index 0000000..75c37ae --- /dev/null +++ b/packages/linter/test/version.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))); + +function read(...segments: string[]): string { + return readFileSync(join(packageDir, ...segments), 'utf8'); +} + +describe('the published version', () => { + it('matches the crate the package downloads', () => { + const published = (JSON.parse(read('package.json')) as { version: string }) + .version; + const crate = /^version = "(.+)"$/m.exec( + read('..', '..', 'crates', 'compact-lint', 'Cargo.toml'), + ); + + expect(crate?.[1]).toBe(published); + }); +}); diff --git a/packages/linter/tsconfig.json b/packages/linter/tsconfig.json new file mode 100644 index 0000000..f4bcef1 --- /dev/null +++ b/packages/linter/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@tsconfig/node24/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"], + "declaration": true, + "skipLibCheck": true, + "sourceMap": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/linter/turbo.json b/packages/linter/turbo.json new file mode 100644 index 0000000..d671009 --- /dev/null +++ b/packages/linter/turbo.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"], + "inputs": ["src/**/*.ts", "tsconfig.json"], + "cache": true + } + } +} diff --git a/packages/linter/vitest.config.ts b/packages/linter/vitest.config.ts new file mode 100644 index 0000000..a07a60b --- /dev/null +++ b/packages/linter/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + reporters: 'verbose', + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json-summary'], + // The bin runs in a subprocess, which v8 coverage does not observe. + include: ['src/binary.ts'], + thresholds: { + statements: 95, + branches: 90, + functions: 100, + lines: 95, + }, + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index 583cabb..6a5f237 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1180,6 +1180,19 @@ __metadata: languageName: unknown linkType: soft +"@openzeppelin/compact-linter@workspace:packages/linter": + version: 0.0.0-use.local + resolution: "@openzeppelin/compact-linter@workspace:packages/linter" + dependencies: + "@tsconfig/node24": "npm:^24.0.3" + "@types/node": "npm:26.1.2" + typescript: "npm:^6.0.3" + vitest: "npm:^4.1.9" + bin: + compact-linter: dist/compactLinter.js + languageName: unknown + linkType: soft + "@openzeppelin/compact-simulator@workspace:packages/simulator": version: 0.0.0-use.local resolution: "@openzeppelin/compact-simulator@workspace:packages/simulator"