diff --git a/.github/scripts/bump-version.mjs b/.github/scripts/bump-version.mjs new file mode 100644 index 0000000..288f78a --- /dev/null +++ b/.github/scripts/bump-version.mjs @@ -0,0 +1,143 @@ +import { readFile, writeFile } from "node:fs/promises"; + +const bump = process.argv[2]; +const supportedBumps = new Set(["patch", "minor", "major"]); + +if (!supportedBumps.has(bump)) { + throw new Error(`Expected patch, minor, or major; received: ${bump ?? ""}`); +} + +const packagePaths = [ + "package.json", + "bandersnatch/package.json", + "bandersnatch/npm/darwin-arm64/package.json", + "bandersnatch/npm/linux-x64-gnu/package.json", + "native/package.json", +]; +const cargoManifestPaths = [ + "bandersnatch/core/Cargo.toml", + "bandersnatch/native-binding/Cargo.toml", + "bandersnatch/wasm-binding/Cargo.toml", +]; +const nativePackages = [ + "@typeberry/bandersnatch-native-darwin-arm64", + "@typeberry/bandersnatch-native-linux-x64-gnu", +]; + +const rootPackage = JSON.parse(await readFile("package.json", "utf8")); +const currentVersion = rootPackage.version; +const versionParts = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)$/); + +if (!versionParts) { + throw new Error(`Root package version is not a stable semantic version: ${currentVersion}`); +} + +let [, major, minor, patch] = versionParts.map(Number); + +if (bump === "major") { + major += 1; + minor = 0; + patch = 0; +} else if (bump === "minor") { + minor += 1; + patch = 0; +} else { + patch += 1; +} + +const nextVersion = `${major}.${minor}.${patch}`; + +function assertCurrentVersion(actual, location) { + if (actual !== currentVersion) { + throw new Error( + `Expected ${location} to use ${currentVersion}, but found ${actual}`, + ); + } +} + +function updateNativeDependencies(pkg, location) { + for (const dependency of nativePackages) { + if (pkg.optionalDependencies?.[dependency] !== undefined) { + assertCurrentVersion( + pkg.optionalDependencies[dependency], + `${location} optional dependency ${dependency}`, + ); + pkg.optionalDependencies[dependency] = nextVersion; + } + } +} + +for (const packagePath of packagePaths) { + const pkg = JSON.parse(await readFile(packagePath, "utf8")); + assertCurrentVersion(pkg.version, packagePath); + pkg.version = nextVersion; + updateNativeDependencies(pkg, packagePath); + await writeFile(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); +} + +const packageLockPath = "package-lock.json"; +const packageLock = JSON.parse(await readFile(packageLockPath, "utf8")); +const lockPackages = [ + "", + "bandersnatch", + "bandersnatch/npm/darwin-arm64", + "bandersnatch/npm/linux-x64-gnu", + "native", +]; + +assertCurrentVersion(packageLock.version, `${packageLockPath} root`); +packageLock.version = nextVersion; + +for (const packagePath of lockPackages) { + const pkg = packageLock.packages?.[packagePath]; + if (!pkg) { + throw new Error(`Missing ${packageLockPath} entry: ${packagePath || ""}`); + } + + assertCurrentVersion( + pkg.version, + `${packageLockPath} entry ${packagePath || ""}`, + ); + pkg.version = nextVersion; + updateNativeDependencies( + pkg, + `${packageLockPath} entry ${packagePath || ""}`, + ); +} + +await writeFile(packageLockPath, `${JSON.stringify(packageLock, null, 2)}\n`); + +for (const manifestPath of cargoManifestPaths) { + let manifest = await readFile(manifestPath, "utf8"); + const versionLine = `version = "${currentVersion}"`; + + if (!manifest.includes(versionLine)) { + throw new Error(`Expected ${manifestPath} to contain ${versionLine}`); + } + + manifest = manifest.replace(versionLine, `version = "${nextVersion}"`); + await writeFile(manifestPath, manifest); +} + +const cargoLockPath = "bandersnatch/Cargo.lock"; +let cargoLock = await readFile(cargoLockPath, "utf8"); + +for (const packageName of [ + "bandersnatch-core", + "bandersnatch-native", + "bandersnatch-wasm", +]) { + const packageVersion = `name = "${packageName}"\nversion = "${currentVersion}"`; + + if (!cargoLock.includes(packageVersion)) { + throw new Error(`Expected ${cargoLockPath} to contain ${packageVersion}`); + } + + cargoLock = cargoLock.replace( + packageVersion, + `name = "${packageName}"\nversion = "${nextVersion}"`, + ); +} + +await writeFile(cargoLockPath, cargoLock); +process.stdout.write(nextVersion); diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml new file mode 100644 index 0000000..c6af179 --- /dev/null +++ b/.github/workflows/draft-release.yml @@ -0,0 +1,49 @@ +name: Create draft release + +on: + pull_request: + branches: [main] + types: [closed] + +concurrency: + group: draft-release-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + create-draft: + if: >- + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + + - name: Verify release version + id: version + env: + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + package_version=$(node -p "require('./package.json').version") + branch_version="${RELEASE_BRANCH#release/v}" + + if [ "$package_version" != "$branch_version" ]; then + echo "::error::Package version $package_version does not match release branch version $branch_version" + exit 1 + fi + + echo "version=$package_version" >> "$GITHUB_OUTPUT" + + - name: Create draft GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + target_commitish: ${{ github.sha }} + generate_release_notes: true + draft: true + prerelease: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c385761..666898b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,18 +1,43 @@ name: Publish on: - workflow_dispatch: - push: - branches: [ "main" ] + release: + types: [published] + +concurrency: + group: publish-${{ github.event.release.tag_name }} + cancel-in-progress: false + +permissions: + contents: read env: CARGO_TERM_COLOR: always jobs: + verify-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} + - name: Verify release version + run: | + tag="${{ github.event.release.tag_name }}" + package_version=$(node -p "require('./package.json').version") + + if [ "$tag" != "v$package_version" ]; then + echo "::error::Release tag $tag does not match package version v$package_version" + exit 1 + fi + build-wasm: + needs: verify-release runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} - name: Use Node.js uses: actions/setup-node@v6 with: @@ -34,6 +59,7 @@ jobs: reed-solomon/pkg/ build-native: + needs: verify-release strategy: fail-fast: false matrix: @@ -50,6 +76,8 @@ jobs: steps: - uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} - name: Use Node.js uses: actions/setup-node@v6 with: @@ -68,13 +96,15 @@ jobs: if-no-files-found: error publish: - needs: [build-wasm, build-native] + needs: [verify-release, build-wasm, build-native] runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} - name: Use Node.js uses: actions/setup-node@v6 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b20278c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Prepare release + +on: + workflow_dispatch: + inputs: + bump: + description: Version bump type + required: true + default: patch + type: choice + options: + - patch + - minor + - major + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Bump versions + id: version + run: | + version=$(node .github/scripts/bump-version.mjs "${{ inputs.bump }}") + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Validate version files + run: | + cargo metadata \ + --manifest-path bandersnatch/Cargo.toml \ + --format-version 1 \ + --locked \ + --no-deps >/dev/null + node -e "JSON.parse(require('fs').readFileSync('package-lock.json'))" + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.PR_APP_ID }} + private-key: ${{ secrets.PR_APP_PRIVATE_KEY }} + + - name: Create version bump pull request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.app-token.outputs.token }} + base: main + branch: release/v${{ steps.version.outputs.version }} + delete-branch: true + commit-message: "Bump version to ${{ steps.version.outputs.version }}" + title: "Bump version to ${{ steps.version.outputs.version }}" + body: | + Bumps the release version to `${{ steps.version.outputs.version }}`. + + After merging this PR, a draft `v${{ steps.version.outputs.version }}` release will be created from the merged commit. + + - name: Write summary + run: | + { + echo "## Version bump v${{ steps.version.outputs.version }} prepared" + echo + echo "1. Merge \`release/v${{ steps.version.outputs.version }}\`." + echo "2. The merge will create a draft \`v${{ steps.version.outputs.version }}\` release." + echo "3. Publish the draft release to publish npm version \`${{ steps.version.outputs.version }}-\`." + } >> "$GITHUB_STEP_SUMMARY"