mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-10-31 11:20:08 +00:00
## Summary
The Rust crate is format-checked but never linted. This fixes the
crate's existing Clippy warnings and adds `cargo clippy -D warnings` to
the pre-push hook and to CI. To keep Rust and TypeScript linting
independent, `lint` is split into `lint:ts` and `lint:rust`, and hooks,
CI and the release script call the half they need.
## Changes
- **Clippy fixes.** In `backup.rs`, two nested `if let … { if … }`
blocks become let-chains (`collapsible_if`). In `migration_patch.rs`,
`apply_checksum_patch` takes `&Path` instead of `&PathBuf` (`ptr_arg`).
No behaviour change.
- **Scripts.** `package.json` adds `lint:ts` / `lint:ts:fix` (ESLint)
and `lint:rust` / `lint:rust:fix` (`cargo fmt` plus `cargo +1.99.0
clippy --all-targets --all-features -D warnings`). `lint` and `lint:fix`
run both halves through `scripts/pm.js`.
- **Pre-push hook.** `lefthook.yml` runs `lint:ts` and `lint:rust`
through `scripts/pm.js`, under the same globs as before.
- **CI.** `rust-lint.yml` gains a `clippy` job on Rust 1.99.0 with the
Linux WebKit/GTK build dependencies and a Rust cache.
`lint-and-typecheck.yml` runs `lint:ts`.
- **Release script.** `scripts/release.js` runs `lint:ts:fix`, so a
release never needs the Rust toolchain or rewrites Rust code.
- **Docs.** `CLAUDE.md`, `README.md` and `docs/development/release.md`
describe the split scripts, the hook commands, the Clippy job, and how
to bump the pinned toolchain.
### Changes to existing behaviour
- `npm run lint` and `npm run lint:fix` now also lint the Rust crate, so
they need `cargo` and the 1.99.0 toolchain with Clippy (`rustup
toolchain install 1.99.0 --component clippy`). `lint:ts` / `lint:ts:fix`
keep the old ESLint-only behaviour.
- The pre-push commands are renamed `lint` → `lint-ts` and `rust-fmt` →
`lint-rust`. Anything that skips or overrides them by name
(`LEFTHOOK_EXCLUDE`, `lefthook-local.yml`) needs the new names.
## Test plan
- [ ] `npm run lint:rust` passes
- [ ] The "Rust Lint" `Clippy` job runs on this PR and passes
- [ ] `npm run lint` runs ESLint, then rustfmt and Clippy
- [ ] Pushing a commit that touches only `.rs` files runs `lint-rust`
but not `lint-ts` or `check`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Mp6GGHh2aY397TuZXyUXKX
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Developer Tooling**
* TypeScript and Rust linting run separately, with Rust formatting and
Clippy checks included in the standard lint workflow.
* Rust lint checks also run in continuous integration, with warnings
treated as errors.
* Pre-push checks now run both TypeScript and Rust linting. Release
preparation runs TypeScript formatting and lint fixes.
* **Documentation**
* Updated build-script descriptions to reflect the TypeScript and Rust
lint commands and their fix options.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
10 KiB
JavaScript
299 lines
10 KiB
JavaScript
/**
|
|
* Cut a release: bump every file that carries the version, commit it on `master`, tag it,
|
|
* and push the branch and tag together. Refuses to run from any other branch.
|
|
*
|
|
* Usage:
|
|
* npm run release -- <major|minor|patch|prerelease|X.Y.Z> [--dry-run]
|
|
*
|
|
* Note the `--`: without it npm eats the flags before the script sees them.
|
|
*
|
|
* Every precondition, including the ones that need the network, is checked before anything
|
|
* is written; a failure after that point resets the branch to where it started and deletes
|
|
* the tag.
|
|
*/
|
|
|
|
import fs from 'fs'
|
|
import { execFileSync } from 'child_process'
|
|
import path from 'path'
|
|
import { bumpVersion, isValidVersion, compareVersions } from './version.js'
|
|
import { pickReleaseRemote } from './remote.js'
|
|
import { detectPackageManager, commandFor } from './package-manager.js'
|
|
|
|
const rootDir = process.cwd()
|
|
|
|
const RELEASE_BRANCH = 'master'
|
|
|
|
const KNOWN_FLAGS = ['--dry-run']
|
|
|
|
const args = process.argv.slice(2)
|
|
const flags = new Set(args.filter((a) => a.startsWith('-')))
|
|
const positionals = args.filter((a) => !a.startsWith('-'))
|
|
const unknownArgs = [
|
|
...[...flags].filter((f) => !KNOWN_FLAGS.includes(f)),
|
|
// A second bump type is a typo, not a request. Ignoring it releases the wrong version.
|
|
...positionals.slice(1),
|
|
]
|
|
const inputArg = positionals[0]
|
|
const dryRun = flags.has('--dry-run')
|
|
|
|
/** Run a command without a shell, so no argument can be interpolated into one. */
|
|
function run(cmd, cmdArgs, opts = {}) {
|
|
return execFileSync(cmd, cmdArgs, { encoding: 'utf8', ...opts })
|
|
}
|
|
|
|
function git(...gitArgs) {
|
|
return run('git', gitArgs).trim()
|
|
}
|
|
|
|
/**
|
|
* Run npm. `aube version` only bumps `package.json`, not `package-lock.json`, so the version
|
|
* bump stays on npm regardless of which package manager installed `node_modules`.
|
|
*/
|
|
function npm(npmArgs, opts = {}) {
|
|
return run(...commandFor('npm', npmArgs), opts)
|
|
}
|
|
|
|
/**
|
|
* A git command whose failure is an answer, not an error. stderr is piped because most of
|
|
* these ask "does this ref exist?", which git answers with `fatal: ...` on the way to no.
|
|
*/
|
|
function tryGit(...gitArgs) {
|
|
try {
|
|
return { ok: true, out: run('git', gitArgs, { stdio: ['ignore', 'pipe', 'pipe'] }).trim() }
|
|
} catch (error) {
|
|
return { ok: false, error }
|
|
}
|
|
}
|
|
|
|
function fail(message, hint) {
|
|
console.error(`\n✗ ${message}`)
|
|
if (hint) console.error(` ${hint}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
/**
|
|
* Where the release is pushed. A configured remote pointing at the release repository when
|
|
* there is one, so the push uses the transport and credentials that remote already has --
|
|
* otherwise the canonical HTTPS URL. See `remote.js` for why this is not simply `origin`,
|
|
* and not a hardcoded URL either.
|
|
*/
|
|
const { ref: REMOTE, label: REMOTE_LABEL } = pickReleaseRemote(tryGit('remote', '-v').out)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pre-flight. Nothing below this block writes anything.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Rejected rather than ignored: a typo in `--dry-run` runs a real release against a
|
|
// repository the author believed was untouched.
|
|
if (unknownArgs.length > 0) {
|
|
fail(
|
|
`Unrecognised argument${unknownArgs.length > 1 ? 's' : ''}: ${unknownArgs.join(', ')}`,
|
|
`Usage: npm run release -- <major|minor|patch|prerelease|X.Y.Z> [${KNOWN_FLAGS.join('] [')}]`,
|
|
)
|
|
}
|
|
|
|
const pkgPath = path.join(rootDir, 'package.json')
|
|
const currentVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
|
|
|
|
if (!inputArg) {
|
|
fail(
|
|
'No version or bump type given.',
|
|
'Usage: npm run release -- <major|minor|patch|prerelease|X.Y.Z> [--dry-run]',
|
|
)
|
|
}
|
|
|
|
if (!isValidVersion(currentVersion)) {
|
|
fail(
|
|
`package.json holds "${currentVersion}", which is not a version this project releases.`,
|
|
'Expected X.Y.Z or X.Y.Z-pre.N.',
|
|
)
|
|
}
|
|
|
|
const newVersion = bumpVersion(currentVersion, inputArg)
|
|
|
|
if (!isValidVersion(newVersion)) {
|
|
fail(
|
|
`"${inputArg}" is not a bump type or a releasable version.`,
|
|
'Use major, minor, patch, prerelease, or a literal X.Y.Z / X.Y.Z-pre.N. Other pre-release ' +
|
|
'shapes are rejected because no workflow builds them: release.yml triggers on ' +
|
|
'v[0-9]+.[0-9]+.[0-9]+ and pre-release.yml on v*-pre*.',
|
|
)
|
|
}
|
|
|
|
if (compareVersions(newVersion, currentVersion) <= 0) {
|
|
fail(
|
|
`${newVersion} does not follow the current version ${currentVersion}.`,
|
|
'A release must move the version forward.',
|
|
)
|
|
}
|
|
|
|
const tag = `v${newVersion}`
|
|
|
|
const status = git('status', '--porcelain')
|
|
if (status) {
|
|
fail(
|
|
'The working tree is not clean.',
|
|
'Commit or stash your changes first — this script commits with `git add .`.',
|
|
)
|
|
}
|
|
|
|
const startBranch = git('rev-parse', '--abbrev-ref', 'HEAD')
|
|
if (startBranch !== RELEASE_BRANCH) {
|
|
fail(
|
|
startBranch === 'HEAD'
|
|
? 'HEAD is detached.'
|
|
: `Releases are cut from ${RELEASE_BRANCH}, but this is ${startBranch}.`,
|
|
`Check out ${RELEASE_BRANCH}, then retry.`,
|
|
)
|
|
}
|
|
const startCommit = git('rev-parse', 'HEAD')
|
|
|
|
if (tryGit('rev-parse', '--verify', `refs/tags/${tag}`).ok) {
|
|
fail(
|
|
`Tag ${tag} already exists locally.`,
|
|
`package.json is at ${currentVersion}, so this branch is behind the tags. Align it, or ` +
|
|
'pass an explicit version.',
|
|
)
|
|
}
|
|
|
|
// The network checks are here, with the cheap ones, because the whole point is that
|
|
// nothing is written until every answer is in.
|
|
console.log(`Checking ${REMOTE_LABEL} for ${tag}...`)
|
|
const remoteRefs = tryGit('ls-remote', REMOTE, `refs/tags/${tag}`, `refs/heads/${startBranch}`)
|
|
if (!remoteRefs.ok) {
|
|
fail(
|
|
'Could not reach the release remote.',
|
|
`${REMOTE_LABEL} — check the network and your credentials. If no configured remote ` +
|
|
'points at the release repository, this falls back to an HTTPS URL, which GitHub no ' +
|
|
'longer accepts a password for.',
|
|
)
|
|
}
|
|
const remoteShas = new Map()
|
|
for (const line of remoteRefs.out.split('\n')) {
|
|
const [sha, ref] = line.split(/\s+/)
|
|
if (sha && ref) remoteShas.set(ref, sha)
|
|
}
|
|
if (remoteShas.has(`refs/tags/${tag}`)) {
|
|
fail(`Tag ${tag} already exists on the remote.`, 'That version has already been released.')
|
|
}
|
|
// A remote branch this checkout is not built on would reject the push after the bump is
|
|
// already committed and tagged.
|
|
const remoteBranchSha = remoteShas.get(`refs/heads/${startBranch}`)
|
|
if (remoteBranchSha && !tryGit('merge-base', '--is-ancestor', remoteBranchSha, 'HEAD').ok) {
|
|
fail(
|
|
`${startBranch} on the remote has commits this checkout lacks.`,
|
|
`Pull them, then retry: git pull ${REMOTE} ${startBranch}`,
|
|
)
|
|
}
|
|
|
|
console.log(`\n${currentVersion} → ${newVersion}`)
|
|
console.log(` push: ${startBranch} + ${tag}`)
|
|
console.log(` remote: ${REMOTE_LABEL}`)
|
|
|
|
if (dryRun) {
|
|
console.log('\n--dry-run: every check passed, nothing was written.')
|
|
process.exit(0)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// From here on the repository is being modified, and failure has to undo it.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const versionFiles = [
|
|
{ path: path.join(rootDir, 'src-tauri/tauri.conf.json'), type: 'json', key: 'version' },
|
|
{
|
|
path: path.join(rootDir, 'src-tauri/Cargo.toml'),
|
|
type: 'toml',
|
|
regex: /^version\s*=\s*"(.*)"/m,
|
|
replace: `version = "${newVersion}"`,
|
|
},
|
|
]
|
|
|
|
let tagCreated = false
|
|
|
|
function rollback() {
|
|
console.error('\nRolling back...')
|
|
// The hard reset discards whatever the failed run had written into the tree.
|
|
const reset = tryGit('reset', '--hard', startCommit).ok
|
|
if (tagCreated) tryGit('tag', '-d', tag)
|
|
if (!reset) console.error(` could not reset ${startBranch}; left the tree as it is.`)
|
|
}
|
|
|
|
try {
|
|
console.log('Updating package.json and package-lock.json...')
|
|
npm(['version', newVersion, '--no-git-tag-version'], { stdio: 'inherit' })
|
|
|
|
for (const file of versionFiles) {
|
|
console.log(`Updating ${path.basename(file.path)}...`)
|
|
let content = fs.readFileSync(file.path, 'utf8')
|
|
|
|
if (file.type === 'json') {
|
|
const json = JSON.parse(content)
|
|
json[file.key] = newVersion
|
|
content = JSON.stringify(json, null, 2) + '\n'
|
|
} else {
|
|
if (!file.regex.test(content)) {
|
|
throw new Error(`No version line found in ${file.path} — the file's format has changed.`)
|
|
}
|
|
content = content.replace(file.regex, file.replace)
|
|
}
|
|
|
|
fs.writeFileSync(file.path, content)
|
|
}
|
|
|
|
console.log('Updating Cargo.lock...')
|
|
run('cargo', ['update', '-p', 'aventura'], {
|
|
cwd: path.join(rootDir, 'src-tauri'),
|
|
stdio: 'inherit',
|
|
})
|
|
|
|
// Formatting runs over the whole repo, so it can sweep in files this release did not
|
|
// touch. Deliberate, but never silent: anything beyond the version files is named below.
|
|
console.log('Formatting and linting...')
|
|
const pm = detectPackageManager(rootDir)
|
|
// npm owns package-lock.json and has just rewritten it; aube must not install or re-resolve.
|
|
const runArgs = pm === 'aube' ? ['run', '--no-install', '--frozen-lockfile'] : ['run']
|
|
run(...commandFor(pm, [...runArgs, 'format']), { stdio: 'inherit' })
|
|
run(...commandFor(pm, [...runArgs, 'lint:ts:fix']), { stdio: 'inherit' })
|
|
|
|
const expected = new Set([
|
|
'package.json',
|
|
'package-lock.json',
|
|
'src-tauri/tauri.conf.json',
|
|
'src-tauri/Cargo.toml',
|
|
'src-tauri/Cargo.lock',
|
|
])
|
|
// `XY <path>`, or `XY <old> -> <new>` for a rename; a path with spaces comes back quoted.
|
|
const touched = git('status', '--porcelain')
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const path = line.slice(3).trim()
|
|
const renamed = path.split(' -> ')
|
|
return renamed[renamed.length - 1].replace(/^"|"$/g, '')
|
|
})
|
|
const collateral = touched.filter((f) => !expected.has(f))
|
|
if (collateral.length > 0) {
|
|
console.log(`\n note: formatting also changed ${collateral.length} unrelated file(s):`)
|
|
for (const f of collateral.slice(0, 10)) console.log(` ${f}`)
|
|
if (collateral.length > 10) console.log(` ... and ${collateral.length - 10} more`)
|
|
console.log(' They are included in the release commit.\n')
|
|
}
|
|
|
|
console.log('Committing...')
|
|
git('add', '.')
|
|
git('commit', '-m', `chore: bump version to ${newVersion}`)
|
|
|
|
console.log(`Tagging ${tag}...`)
|
|
git('tag', tag)
|
|
tagCreated = true
|
|
|
|
console.log(`Pushing ${startBranch} and ${tag}...`)
|
|
git('push', '--atomic', REMOTE, startBranch, tag)
|
|
} catch (error) {
|
|
console.error(`\n✗ Release failed: ${error.message}`)
|
|
rollback()
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log(`\n✓ Released ${tag}; ${startBranch} is at ${newVersion}`)
|