From ebdb7c66cb4f1c6384a6aef7bcbfdba5a9d4a99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Tue, 26 May 2026 10:22:20 +0200 Subject: [PATCH 1/4] feat(ci): automate brownfield gradle plugin releases --- .../release-brownfield-gradle-plugin.yml | 167 ++++++++++++ gradle-plugins/react/PUBLISHING.md | 140 ++++++++-- gradle-plugins/react/README.md | 7 +- .../react/brownfield/build.gradle.kts | 9 +- jreleaser.yml | 35 +++ package.json | 3 + ...nfield-gradle-plugin-release-notes.test.ts | 82 ++++++ ...c-brownfield-gradle-plugin-version.test.ts | 169 ++++++++++++ ...-brownfield-gradle-plugin-release-notes.ts | 244 ++++++++++++++++++ .../sync-brownfield-gradle-plugin-version.ts | 163 ++++++++++++ 10 files changed, 995 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/release-brownfield-gradle-plugin.yml create mode 100644 jreleaser.yml create mode 100644 scripts/__tests__/generate-brownfield-gradle-plugin-release-notes.test.ts create mode 100644 scripts/__tests__/sync-brownfield-gradle-plugin-version.test.ts create mode 100644 scripts/generate-brownfield-gradle-plugin-release-notes.ts create mode 100644 scripts/sync-brownfield-gradle-plugin-version.ts diff --git a/.github/workflows/release-brownfield-gradle-plugin.yml b/.github/workflows/release-brownfield-gradle-plugin.yml new file mode 100644 index 00000000..faf4fd03 --- /dev/null +++ b/.github/workflows/release-brownfield-gradle-plugin.yml @@ -0,0 +1,167 @@ +name: Release Brownfield Gradle Plugin + +on: + push: + tags: + - 'brownfield-gradle-plugin/v*' + workflow_dispatch: + inputs: + ref: + description: 'Git ref to release from' + required: false + default: 'main' + type: string + dry_run: + description: 'Prepare artifacts and notes without publishing' + required: false + default: true + type: boolean + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + validate: + name: Validate release inputs and stage artifacts + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.metadata.outputs.version }} + tag_name: ${{ steps.metadata.outputs.tag_name }} + skip_tag: ${{ steps.metadata.outputs.skip_tag }} + dry_run: ${{ steps.metadata.outputs.dry_run }} + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }} + + - name: Setup + uses: ./.github/actions/setup + with: + restore-turbo-cache: 'false' + + - name: Prepare Android environment + uses: ./.github/actions/prepare-android + with: + free-disk-space: 'false' + run-yarn-build: 'false' + + - name: Derive release metadata + id: metadata + shell: bash + run: | + version=$(sed -n 's/^VERSION=//p' gradle-plugins/react/brownfield/gradle.properties) + if [ -z "$version" ]; then + echo "Could not determine Brownfield Gradle Plugin version" >&2 + exit 1 + fi + + expected_tag="brownfield-gradle-plugin/v${version}" + if [ "${GITHUB_EVENT_NAME}" = "push" ]; then + actual_tag="${GITHUB_REF_NAME}" + skip_tag=true + dry_run=false + else + actual_tag="${expected_tag}" + skip_tag=false + dry_run="${{ inputs.dry_run }}" + fi + + if [ "$actual_tag" != "$expected_tag" ]; then + echo "Tag mismatch: expected ${expected_tag}, got ${actual_tag}" >&2 + exit 1 + fi + + { + echo "version=${version}" + echo "tag_name=${actual_tag}" + echo "skip_tag=${skip_tag}" + echo "dry_run=${dry_run}" + } >> "$GITHUB_OUTPUT" + + - name: Check plugin version sync + run: yarn brownfield:plugin:version:check + + - name: Generate release notes + run: | + mkdir -p out/jreleaser + yarn brownfield:plugin:release-notes \ + --version "${{ steps.metadata.outputs.version }}" \ + --ref HEAD \ + --output out/jreleaser/brownfield-gradle-plugin-release-notes.md + + - name: Stage Maven release artifacts + working-directory: gradle-plugins/react + run: | + ./gradlew :brownfield:publishMavenLocalPublicationToReleaseStagingRepository \ + -PSkipSigning=true \ + --no-daemon \ + --stacktrace + + - name: Upload release notes artifact + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + with: + name: brownfield-gradle-plugin-release-notes + path: out/jreleaser/brownfield-gradle-plugin-release-notes.md + + - name: Upload staged Maven repository artifact + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + with: + name: brownfield-gradle-plugin-staging-repo + path: gradle-plugins/react/brownfield/build/staging-deploy + + publish: + name: Publish Brownfield Gradle Plugin + runs-on: ubuntu-latest + needs: validate + if: github.event_name == 'push' || needs.validate.outputs.dry_run != 'true' + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }} + + - name: Configure Git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Download release notes artifact + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: brownfield-gradle-plugin-release-notes + path: out/jreleaser + + - name: Download staged Maven repository artifact + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: brownfield-gradle-plugin-staging-repo + path: gradle-plugins/react/brownfield/build/staging-deploy + + - name: Run JReleaser + uses: jreleaser/release-action@v2 + with: + arguments: full-release + env: + JRELEASER_PROJECT_VERSION: ${{ needs.validate.outputs.version }} + JRELEASER_GITHUB_SKIP_TAG: ${{ needs.validate.outputs.skip_tag }} + JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.JRELEASER_MAVENCENTRAL_USERNAME }} + JRELEASER_MAVENCENTRAL_PASSWORD: ${{ secrets.JRELEASER_MAVENCENTRAL_PASSWORD }} + JRELEASER_GPG_PUBLIC_KEY: ${{ secrets.JRELEASER_GPG_PUBLIC_KEY }} + JRELEASER_GPG_SECRET_KEY: ${{ secrets.JRELEASER_GPG_SECRET_KEY }} + JRELEASER_GPG_PASSPHRASE: ${{ secrets.JRELEASER_GPG_PASSPHRASE }} + + - name: Upload JReleaser output + if: always() + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + with: + name: brownfield-gradle-plugin-jreleaser-output + path: | + out/jreleaser/trace.log + out/jreleaser/output.properties diff --git a/gradle-plugins/react/PUBLISHING.md b/gradle-plugins/react/PUBLISHING.md index 40913579..3a8b304f 100644 --- a/gradle-plugins/react/PUBLISHING.md +++ b/gradle-plugins/react/PUBLISHING.md @@ -1,36 +1,140 @@ -# Publishing a Signed Plugin +# Brownfield Gradle Plugin Publishing -To publish a signed plugin (currently to `mavenLocal` only), you need to configure the signing key data in plugin's `gradle.properties` file +This document covers two different flows: -> [!IMPORTANT] -> Make sure to clear `~/.m2` directory before starting publishing process +- local snapshot publishing to `mavenLocal()` for development and CI road tests +- Maven Central release publishing through GitHub Actions and JReleaser -### 1. Add Signing Key Data +## Source of truth -Update `gradle.properties` file with the following properties: +The Brownfield Gradle Plugin version lives in: +`gradle-plugins/react/brownfield/gradle.properties` + +Everything else that embeds the plugin version must be synced from that file. Before preparing a release, run: + +```sh +yarn brownfield:plugin:version:check +``` + +If the check reports drift, sync the versioned references with: + +```sh +yarn brownfield:plugin:version:sync ``` -signing.keyId= -signing.password= -signing.secretKeyRingFile= + +## Local snapshot publishing + +Use this when you need the plugin in `~/.m2/repository` for local Android work or CI road tests. + +```sh +yarn brownfield:plugin:publish:local ``` -- `keyId`: The public key ID. -- `password`: The passphrase used when creating the key. -- `secretKeyRingFile`: The absolute path to the private key file. +That command publishes: -### 2. Publish the Plugin +- `com.callstack.react:brownfield-gradle-plugin:-SNAPSHOT` +- to `mavenLocal()` +- without Maven Central release steps -Once the signing key is set up correctly, run the following command: +The signed local variant remains available if you need to inspect the signed output locally: ```sh yarn brownfield:plugin:publish:local:signed ``` -### 3. Output +## Release publishing + +Release publishing is automated by: + +`/.github/workflows/release-brownfield-gradle-plugin.yml` + +The workflow stages Maven artifacts from: + +`gradle-plugins/react/brownfield/build/staging-deploy` + +and then uses the repo-root `jreleaser.yml` configuration to: + +- sign release artifacts +- publish them to Maven Central +- create the GitHub release + +### Required GitHub secrets + +The workflow expects these repository secrets: + +- `JRELEASER_MAVENCENTRAL_USERNAME` +- `JRELEASER_MAVENCENTRAL_PASSWORD` +- `JRELEASER_GPG_PUBLIC_KEY` +- `JRELEASER_GPG_SECRET_KEY` +- `JRELEASER_GPG_PASSPHRASE` + +`GITHUB_TOKEN` is provided by GitHub Actions and is used for the GitHub release step. + +### Tag-triggered release + +The normal release path is a pushed tag that matches: + +`brownfield-gradle-plugin/v` + +Example: + +```sh +git tag brownfield-gradle-plugin/v1.1.0 +git push origin brownfield-gradle-plugin/v1.1.0 +``` + +The workflow validates that the tag version matches `gradle-plugins/react/brownfield/gradle.properties`. + +### Manual workflow dispatch + +Use `workflow_dispatch` when you want to: + +- dry-run the release preparation +- publish from a selected ref without pushing the tag first +- retry after fixing workflow or secret issues + +Inputs: + +- `ref`: git ref to release from, defaults to `main` +- `dry_run`: when `true`, prepare and upload artifacts only + +For `workflow_dispatch`, the workflow derives the release tag from the current plugin version as: + +`brownfield-gradle-plugin/v{{projectVersion}}` + +### What the workflow does + +The validation job: + +- checks out the requested ref with full history +- runs the shared setup and Android environment actions +- verifies plugin version sync +- generates plugin-scoped release notes +- stages the Maven repository with `publishMavenLocalPublicationToReleaseStagingRepository -PSkipSigning=true` +- uploads the staged Maven repository and release notes as artifacts + +The publish job: + +- runs automatically on tag pushes +- runs on manual dispatch only when `dry_run` is `false` +- downloads the staged artifacts from the validation job +- runs `jreleaser/release-action@v2` + +## Release notes + +Plugin release notes are generated with: + +```sh +yarn brownfield:plugin:release-notes --version --ref --output +``` + +The generator: -If everything is configured properly, the signed plugin will be published to the `~/.m2` repository. +- scopes commits to plugin-relevant paths only +- uses the previous plugin tag when available +- handles the existing legacy plugin tag formats in this repository -### 4. Publishing +## No manual Maven Central upload -Go to `~/.m2/repository`, ZIP created plugin at `com` directory level (make sure there isn't any other plugin inside it) and upload it to [Maven Central](https://central.sonatype.com/) +The old workflow of zipping `~/.m2/repository/com` and uploading it manually to Maven Central is obsolete and should not be used anymore. diff --git a/gradle-plugins/react/README.md b/gradle-plugins/react/README.md index 62d90ef9..dc3ec675 100644 --- a/gradle-plugins/react/README.md +++ b/gradle-plugins/react/README.md @@ -8,6 +8,8 @@ This plugin helps you convert your react-native brownfield implementation into a ### From Remote +Use the latest version published on Maven Central. + To your top level `build.gradle` add ```diff @@ -20,7 +22,7 @@ buildscript { classpath("com.android.tools.build:gradle") classpath("com.facebook.react:react-native-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") -+ classpath("com.callstack.react:brownfield-gradle-plugin:1.1.0") ++ classpath("com.callstack.react:brownfield-gradle-plugin:") } } ``` @@ -59,7 +61,7 @@ buildscript { classpath("com.android.tools.build:gradle") classpath("com.facebook.react:react-native-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") -+ classpath("com.callstack.react:brownfield-gradle-plugin:1.1.0-SNAPSHOT") ++ classpath("com.callstack.react:brownfield-gradle-plugin:-SNAPSHOT") } } ``` @@ -155,6 +157,7 @@ reactBrownfield { - We are using `ktlint` and `detekt` for formatting and linting - You can run `./gradlew :brownfield:lint` to auto-format and detect linting issues, or use the root workspace script `yarn run gradle-plugin:lint` +- Maintainers should use [PUBLISHING.md](./PUBLISHING.md) for the release workflow, release notes generation, and Maven Central publication process ## Architecture diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index 5f0a263b..5717b399 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -41,14 +41,11 @@ gradlePlugin { val baseVersion = property("VERSION").toString() val isSnapshot = project.findProperty("IS_SNAPSHOT") == "true" +val releaseStagingRepositoryDir = layout.buildDirectory.dir("staging-deploy") version = if (isSnapshot) "$baseVersion-SNAPSHOT" else baseVersion publishing { - publications.withType().configureEach { - artifactId = property("ARTIFACT_ID").toString() - } - publications { create("mavenLocal") { from(components["java"]) @@ -87,6 +84,10 @@ publishing { repositories { mavenLocal() + maven { + name = "releaseStaging" + url = uri(releaseStagingRepositoryDir) + } } } diff --git a/jreleaser.yml b/jreleaser.yml new file mode 100644 index 00000000..d241e8bd --- /dev/null +++ b/jreleaser.yml @@ -0,0 +1,35 @@ +project: + name: brownfield-gradle-plugin + description: React Native Brownfield Gradle Plugin + longDescription: Helps you generate Fat Aar for React Native Brownfield Projects + authors: + - Callstack Team + license: MIT + inceptionYear: 2025 + links: + homepage: https://github.com/callstack/react-native-brownfield + +release: + github: + tagName: brownfield-gradle-plugin/v{{projectVersion}} + releaseName: Brownfield Gradle Plugin {{projectVersion}} + owner: callstack + name: react-native-brownfield + skipTag: true + overwrite: true + changelog: + external: out/jreleaser/brownfield-gradle-plugin-release-notes.md + +signing: + pgp: + active: ALWAYS + armored: true + +deploy: + maven: + mavenCentral: + sonatype: + active: RELEASE + url: https://central.sonatype.com/api/v1/publisher + stagingRepositories: + - gradle-plugins/react/brownfield/build/staging-deploy diff --git a/package.json b/package.json index 3fe20689..8e4d3e84 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "brownfield:plugin:publish:local:signed": "bash ./gradle-plugins/publish-to-maven-local.sh", "build:brownfield": "turbo run build:brownfield", "build:docs": "turbo run build:docs", + "brownfield:plugin:release-notes": "node --experimental-strip-types --no-warnings ./scripts/generate-brownfield-gradle-plugin-release-notes.ts", + "brownfield:plugin:version:check": "node --experimental-strip-types --no-warnings ./scripts/sync-brownfield-gradle-plugin-version.ts --check", + "brownfield:plugin:version:sync": "node --experimental-strip-types --no-warnings ./scripts/sync-brownfield-gradle-plugin-version.ts", "generate:store": "node --experimental-strip-types --no-warnings ./scripts/generate-store.ts", "skillgym:brownie": "skillgym run skillgym/suites/brownie-suite.ts", "skillgym:navigation": "skillgym run skillgym/suites/brownfield-navigation-suite.ts" diff --git a/scripts/__tests__/generate-brownfield-gradle-plugin-release-notes.test.ts b/scripts/__tests__/generate-brownfield-gradle-plugin-release-notes.test.ts new file mode 100644 index 00000000..59d3de11 --- /dev/null +++ b/scripts/__tests__/generate-brownfield-gradle-plugin-release-notes.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + findPreviousPluginTag, + renderReleaseNotes, + type PluginCommit, +} from '../generate-brownfield-gradle-plugin-release-notes.ts'; + +test('finds the latest plugin tag older than the target version across legacy tag formats', () => { + const previousTag = findPreviousPluginTag( + [ + '@callsack/brownfield-gradle-plugin@0.7.3', + '@callstack/brownfield-gradle-plugin@v1.0.0', + 'brownfield-gradle-plugin/v1.2.0', + 'brownfield@3.10.0', + ], + '1.1.0' + ); + + assert.equal(previousTag, '@callstack/brownfield-gradle-plugin@v1.0.0'); +}); + +test('renders scoped release notes grouped by commit category', () => { + const commits: PluginCommit[] = [ + { + hash: '80e6364', + subject: + 'feat: strip SO files by default, deprecate experimental option in favor of useStrippedSoFiles (#326)', + }, + { + hash: 'dd8b8a0', + subject: 'fix: broken support for custom `appProjectName` in Gradle Plugin (#275)', + }, + { + hash: '6ea8da9', + subject: 'chore: gradle plugin housekeeping (#249)', + }, + { + hash: '8e68b38', + subject: 'refactor(android): simplify debug bundle variant mapping', + }, + { + hash: '58dc434', + subject: 'docs: update landing with new features (#222)', + }, + ]; + + const notes = renderReleaseNotes({ + version: '1.1.0', + ref: 'HEAD', + previousTag: '@callstack/brownfield-gradle-plugin@v1.0.0', + commits, + }); + + assert.match(notes, /^# Brownfield Gradle Plugin 1\.1\.0/m); + assert.match( + notes, + /Compare: `@callstack\/brownfield-gradle-plugin@v1\.0\.0\.\.\.HEAD`/ + ); + assert.match(notes, /## Features[\s\S]*80e6364/); + assert.match(notes, /## Fixes[\s\S]*dd8b8a0/); + assert.match(notes, /## Docs[\s\S]*58dc434/); + assert.match(notes, /## Maintenance[\s\S]*6ea8da9[\s\S]*8e68b38/); + assert.doesNotMatch(notes, /## Other Changes/); +}); + +test('renders a fallback heading when no previous plugin tag exists', () => { + const notes = renderReleaseNotes({ + version: '0.4.0', + ref: 'HEAD', + previousTag: null, + commits: [ + { + hash: 'af43a84', + subject: 'chore: bump version to 0.4.0 (#119)', + }, + ], + }); + + assert.match(notes, /Compare: `initial history\.\.\.HEAD`/); +}); diff --git a/scripts/__tests__/sync-brownfield-gradle-plugin-version.test.ts b/scripts/__tests__/sync-brownfield-gradle-plugin-version.test.ts new file mode 100644 index 00000000..e15c7751 --- /dev/null +++ b/scripts/__tests__/sync-brownfield-gradle-plugin-version.test.ts @@ -0,0 +1,169 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + syncBrownfieldGradlePluginVersion, + type SyncOptions, +} from '../sync-brownfield-gradle-plugin-version.ts'; + +function writeRepoFile( + repoRoot: string, + relativePath: string, + contents: string +): void { + const targetPath = path.join(repoRoot, relativePath); + mkdirSync(path.dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, contents, 'utf8'); +} + +function createFixtureRepo(): string { + const repoRoot = mkdtempSync( + path.join(tmpdir(), 'brownfield-gradle-plugin-version-sync-') + ); + + writeRepoFile( + repoRoot, + 'gradle-plugins/react/brownfield/gradle.properties', + ['PROJECT_ID=com.callstack.react.brownfield', 'VERSION=2.3.4', ''].join('\n') + ); + + writeRepoFile( + repoRoot, + 'packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts', + [ + "export const BROWNFIELD_PLUGIN_VERSION = '1.1.0';", + 'export const brownfieldGradlePluginDependency = `classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}")`;', + '', + ].join('\n') + ); + + writeRepoFile( + repoRoot, + 'apps/scripts/prepare-android-build-gradle-for-ci.ts', + [ + "const SNAPSHOT_VERSION = '1.1.0-SNAPSHOT';", + 'console.log(SNAPSHOT_VERSION);', + '', + ].join('\n') + ); + + writeRepoFile( + repoRoot, + 'apps/RNApp/android/build.gradle', + [ + 'buildscript {', + ' dependencies {', + ' classpath("com.callstack.react:brownfield-gradle-plugin:1.1.0-SNAPSHOT")', + ' }', + '}', + '', + ].join('\n') + ); + + writeRepoFile( + repoRoot, + 'gradle-plugins/react/README.md', + 'Use the latest version published on Maven Central.\n' + ); + + return repoRoot; +} + +function readRepoFile(repoRoot: string, relativePath: string): string { + return readFileSync(path.join(repoRoot, relativePath), 'utf8'); +} + +test('syncs all known Brownfield Gradle Plugin version references', async () => { + const repoRoot = createFixtureRepo(); + + try { + const result = syncBrownfieldGradlePluginVersion({ repoRoot }); + + assert.equal(result.version, '2.3.4'); + assert.equal(result.snapshotVersion, '2.3.4-SNAPSHOT'); + assert.deepEqual(result.changedFiles.sort(), [ + 'apps/RNApp/android/build.gradle', + 'apps/scripts/prepare-android-build-gradle-for-ci.ts', + 'packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts', + ]); + + assert.match( + readRepoFile( + repoRoot, + 'packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts' + ), + /BROWNFIELD_PLUGIN_VERSION = '2\.3\.4'/ + ); + assert.match( + readRepoFile(repoRoot, 'apps/scripts/prepare-android-build-gradle-for-ci.ts'), + /SNAPSHOT_VERSION = '2\.3\.4-SNAPSHOT'/ + ); + assert.match( + readRepoFile(repoRoot, 'apps/RNApp/android/build.gradle'), + /brownfield-gradle-plugin:2\.3\.4-SNAPSHOT/ + ); + assert.equal( + readRepoFile(repoRoot, 'gradle-plugins/react/README.md'), + 'Use the latest version published on Maven Central.\n' + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test('check mode reports drift without rewriting files', async () => { + const repoRoot = createFixtureRepo(); + + try { + const before = readRepoFile( + repoRoot, + 'apps/scripts/prepare-android-build-gradle-for-ci.ts' + ); + + const result = syncBrownfieldGradlePluginVersion({ + repoRoot, + check: true, + } satisfies SyncOptions); + + assert.equal(result.inSync, false); + assert.deepEqual(result.changedFiles, [ + 'packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts', + 'apps/scripts/prepare-android-build-gradle-for-ci.ts', + 'apps/RNApp/android/build.gradle', + ]); + assert.equal( + readRepoFile(repoRoot, 'apps/scripts/prepare-android-build-gradle-for-ci.ts'), + before + ); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test('fails loudly when an expected version reference cannot be found', async () => { + const repoRoot = createFixtureRepo(); + + try { + writeRepoFile( + repoRoot, + 'apps/RNApp/android/build.gradle', + ['buildscript {', ' dependencies {', ' }', '}', ''].join('\n') + ); + + assert.throws(() => syncBrownfieldGradlePluginVersion({ repoRoot }), { + message: + /Could not locate expected Brownfield Gradle Plugin version pattern in apps\/RNApp\/android\/build\.gradle/, + }); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/generate-brownfield-gradle-plugin-release-notes.ts b/scripts/generate-brownfield-gradle-plugin-release-notes.ts new file mode 100644 index 00000000..083b934b --- /dev/null +++ b/scripts/generate-brownfield-gradle-plugin-release-notes.ts @@ -0,0 +1,244 @@ +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface PluginCommit { + hash: string; + subject: string; +} + +interface RenderOptions { + version: string; + ref: string; + previousTag: string | null; + commits: PluginCommit[]; +} + +interface ScriptOptions { + version: string; + ref: string; + output: string; + repoRoot?: string; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DEFAULT_REPO_ROOT = path.resolve(__dirname, '..'); + +export const PLUGIN_RELEVANT_PATHS = [ + 'gradle-plugins/react', + 'packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts', + 'apps/scripts/prepare-android-build-gradle-for-ci.ts', + 'gradle-plugins/react/README.md', + 'apps/RNApp/android/build.gradle', +]; + +const PLUGIN_TAG_PATTERNS = [ + /^brownfield-gradle-plugin\/v(?\d+\.\d+\.\d+)$/, + /^@callstack\/brownfield-gradle-plugin@v?(?\d+\.\d+\.\d+)$/, + /^@callsack\/brownfield-gradle-plugin@v?(?\d+\.\d+\.\d+)$/, +]; + +function parseVersion(version: string): [number, number, number] { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!match) { + throw new Error(`Unsupported semantic version: ${version}`); + } + + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareVersions(left: string, right: string): number { + const [leftMajor, leftMinor, leftPatch] = parseVersion(left); + const [rightMajor, rightMinor, rightPatch] = parseVersion(right); + + if (leftMajor !== rightMajor) return leftMajor - rightMajor; + if (leftMinor !== rightMinor) return leftMinor - rightMinor; + return leftPatch - rightPatch; +} + +function extractPluginTagVersion(tag: string): string | null { + for (const pattern of PLUGIN_TAG_PATTERNS) { + const match = tag.match(pattern); + if (match?.groups?.version) { + return match.groups.version; + } + } + + return null; +} + +export function findPreviousPluginTag( + tags: string[], + targetVersion: string +): string | null { + return tags + .map((tag) => ({ tag, version: extractPluginTagVersion(tag) })) + .filter((entry): entry is { tag: string; version: string } => { + return entry.version !== null && compareVersions(entry.version, targetVersion) < 0; + }) + .sort((left, right) => compareVersions(right.version, left.version))[0]?.tag ?? null; +} + +function categorizeCommit(subject: string): 'Features' | 'Fixes' | 'Docs' | 'Maintenance' | 'Other Changes' { + const prefix = subject.match(/^([a-z]+)(?:\([^)]+\))?!?:/i)?.[1]?.toLowerCase(); + + switch (prefix) { + case 'feat': + return 'Features'; + case 'fix': + return 'Fixes'; + case 'docs': + return 'Docs'; + case 'chore': + case 'refactor': + return 'Maintenance'; + default: + return 'Other Changes'; + } +} + +export function renderReleaseNotes(options: RenderOptions): string { + const sectionOrder: Array> = [ + 'Features', + 'Fixes', + 'Docs', + 'Maintenance', + 'Other Changes', + ]; + + const sections = new Map(sectionOrder.map((section) => [section, [] as string[]])); + + for (const commit of options.commits) { + sections.get(categorizeCommit(commit.subject))!.push( + `- \`${commit.hash}\` ${commit.subject}` + ); + } + + const compareRange = options.previousTag + ? `${options.previousTag}...${options.ref}` + : `initial history...${options.ref}`; + + const lines: string[] = [ + `# Brownfield Gradle Plugin ${options.version}`, + '', + `Compare: \`${compareRange}\``, + '', + ]; + + for (const section of sectionOrder) { + const entries = sections.get(section)!; + if (entries.length === 0) continue; + + lines.push(`## ${section}`, '', ...entries, ''); + } + + return `${lines.join('\n').trimEnd()}\n`; +} + +function runGit(repoRoot: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); +} + +function listPluginTags(repoRoot: string): string[] { + const output = runGit(repoRoot, ['tag', '--sort=creatordate']); + return output === '' ? [] : output.split('\n'); +} + +function collectCommits( + repoRoot: string, + previousTag: string | null, + ref: string +): PluginCommit[] { + const range = previousTag ? `${previousTag}..${ref}` : ref; + const output = runGit(repoRoot, [ + 'log', + '--format=%H%x09%s', + range, + '--', + ...PLUGIN_RELEVANT_PATHS, + ]); + + if (output === '') return []; + + return output.split('\n').map((line) => { + const [hash, subject] = line.split('\t'); + return { + hash: hash.slice(0, 7), + subject, + }; + }); +} + +function parseArgs(argv: string[]): ScriptOptions { + const options: Partial = { ref: 'HEAD' }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const value = argv[index + 1]; + + switch (arg) { + case '--version': + options.version = value; + index += 1; + break; + case '--ref': + options.ref = value; + index += 1; + break; + case '--output': + options.output = value; + index += 1; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.version) { + throw new Error('Missing required argument: --version'); + } + + if (!options.output) { + throw new Error('Missing required argument: --output'); + } + + return options as ScriptOptions; +} + +export function generateReleaseNotes(options: ScriptOptions): { + previousTag: string | null; + notes: string; + commits: PluginCommit[]; +} { + const repoRoot = options.repoRoot ?? DEFAULT_REPO_ROOT; + const tags = listPluginTags(repoRoot); + const previousTag = findPreviousPluginTag(tags, options.version); + const commits = collectCommits(repoRoot, previousTag, options.ref); + const notes = renderReleaseNotes({ + version: options.version, + ref: options.ref, + previousTag, + commits, + }); + + return { previousTag, notes, commits }; +} + +function main(): void { + const options = parseArgs(process.argv.slice(2)); + const result = generateReleaseNotes(options); + fs.writeFileSync(options.output, result.notes, 'utf8'); + + console.log(`previous plugin tag: ${result.previousTag ?? 'none'}`); + console.log(`collected ${result.commits.length} plugin-relevant commits`); + console.log(`wrote ${options.output}`); +} + +if (process.argv[1] === __filename) { + main(); +} diff --git a/scripts/sync-brownfield-gradle-plugin-version.ts b/scripts/sync-brownfield-gradle-plugin-version.ts new file mode 100644 index 00000000..b64ac302 --- /dev/null +++ b/scripts/sync-brownfield-gradle-plugin-version.ts @@ -0,0 +1,163 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface SyncOptions { + repoRoot?: string; + check?: boolean; +} + +export interface SyncResult { + version: string; + snapshotVersion: string; + changedFiles: string[]; + inSync: boolean; +} + +interface Versions { + version: string; + snapshotVersion: string; +} + +type Transformer = (contents: string, versions: Versions) => string; + +interface SyncTarget { + relativePath: string; + transform: Transformer; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DEFAULT_REPO_ROOT = path.resolve(__dirname, '..'); + +const VERSION_SOURCE_PATH = 'gradle-plugins/react/brownfield/gradle.properties'; + +const SYNC_TARGETS: SyncTarget[] = [ + { + relativePath: + 'packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts', + transform: (contents, versions) => + replaceRequired( + contents, + /export const BROWNFIELD_PLUGIN_VERSION = '[^']+';/, + `export const BROWNFIELD_PLUGIN_VERSION = '${versions.version}';`, + 'packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts' + ), + }, + { + relativePath: 'apps/scripts/prepare-android-build-gradle-for-ci.ts', + transform: (contents, versions) => + replaceRequired( + contents, + /const SNAPSHOT_VERSION = '[^']+';/, + `const SNAPSHOT_VERSION = '${versions.snapshotVersion}';`, + 'apps/scripts/prepare-android-build-gradle-for-ci.ts' + ), + }, + { + relativePath: 'apps/RNApp/android/build.gradle', + transform: (contents, versions) => + replaceRequired( + contents, + /classpath\("com\.callstack\.react:brownfield-gradle-plugin:[^")]+-SNAPSHOT"\)/, + `classpath("com.callstack.react:brownfield-gradle-plugin:${versions.snapshotVersion}")`, + 'apps/RNApp/android/build.gradle' + ), + }, +]; + +function replaceRequired( + contents: string, + pattern: RegExp, + replacement: string, + relativePath: string +): string { + if (!pattern.test(contents)) { + throw new Error( + `Could not locate expected Brownfield Gradle Plugin version pattern in ${relativePath}` + ); + } + + return contents.replace(pattern, replacement); +} + +function readVersions(repoRoot: string): Versions { + const propertiesPath = path.join(repoRoot, VERSION_SOURCE_PATH); + const properties = fs.readFileSync(propertiesPath, 'utf8'); + const versionMatch = properties.match(/^VERSION=(.+)$/m); + + if (!versionMatch) { + throw new Error(`Could not locate VERSION in ${VERSION_SOURCE_PATH}`); + } + + const version = versionMatch[1].trim(); + return { + version, + snapshotVersion: `${version}-SNAPSHOT`, + }; +} + +export function syncBrownfieldGradlePluginVersion( + options: SyncOptions = {} +): SyncResult { + const repoRoot = options.repoRoot ?? DEFAULT_REPO_ROOT; + const versions = readVersions(repoRoot); + const changedFiles: string[] = []; + + for (const target of SYNC_TARGETS) { + const targetPath = path.join(repoRoot, target.relativePath); + const originalContents = fs.readFileSync(targetPath, 'utf8'); + const updatedContents = target.transform(originalContents, versions); + + if (updatedContents !== originalContents) { + changedFiles.push(target.relativePath); + + if (!options.check) { + fs.writeFileSync(targetPath, updatedContents, 'utf8'); + } + } + } + + return { + ...versions, + changedFiles, + inSync: changedFiles.length === 0, + }; +} + +function parseArgs(argv: string[]): SyncOptions { + return { + check: argv.includes('--check'), + }; +} + +function report(result: SyncResult, checkMode: boolean): void { + if (result.inSync) { + console.log('Brownfield Gradle Plugin versions already in sync'); + return; + } + + for (const changedFile of result.changedFiles) { + console.log(`${checkMode ? 'out of sync' : 'synced'} ${changedFile}`); + } + + console.log( + checkMode + ? 'Brownfield Gradle Plugin version drift detected' + : 'Brownfield Gradle Plugin version sync complete' + ); +} + +function main(): void { + const options = parseArgs(process.argv.slice(2)); + const result = syncBrownfieldGradlePluginVersion(options); + report(result, options.check === true); + + if (options.check && !result.inSync) { + process.exitCode = 1; + } +} + +if (process.argv[1] === __filename) { + main(); +} From 8488741af3935cdfbe5a99b46b6bd1330ecf90a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Tue, 26 May 2026 10:50:57 +0200 Subject: [PATCH 2/4] chore(ci): add temporary PR trigger for bgp release workflow --- .../release-brownfield-gradle-plugin.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-brownfield-gradle-plugin.yml b/.github/workflows/release-brownfield-gradle-plugin.yml index faf4fd03..7832ae0a 100644 --- a/.github/workflows/release-brownfield-gradle-plugin.yml +++ b/.github/workflows/release-brownfield-gradle-plugin.yml @@ -4,6 +4,17 @@ on: push: tags: - 'brownfield-gradle-plugin/v*' + # Temporary pre-merge validation trigger for this PR. Remove after testing. + pull_request: + paths: + - '.github/workflows/release-brownfield-gradle-plugin.yml' + - 'jreleaser.yml' + - 'gradle-plugins/react/**' + - 'scripts/sync-brownfield-gradle-plugin-version.ts' + - 'scripts/generate-brownfield-gradle-plugin-release-notes.ts' + - 'scripts/__tests__/sync-brownfield-gradle-plugin-version.test.ts' + - 'scripts/__tests__/generate-brownfield-gradle-plugin-release-notes.test.ts' + - 'package.json' workflow_dispatch: inputs: ref: @@ -63,10 +74,14 @@ jobs: actual_tag="${GITHUB_REF_NAME}" skip_tag=true dry_run=false - else + elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then actual_tag="${expected_tag}" skip_tag=false dry_run="${{ inputs.dry_run }}" + else + actual_tag="${expected_tag}" + skip_tag=true + dry_run=true fi if [ "$actual_tag" != "$expected_tag" ]; then @@ -116,7 +131,7 @@ jobs: name: Publish Brownfield Gradle Plugin runs-on: ubuntu-latest needs: validate - if: github.event_name == 'push' || needs.validate.outputs.dry_run != 'true' + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && needs.validate.outputs.dry_run != 'true') permissions: contents: write steps: From b4eff6a9e10dd87bab666263600cb3f5d2c18301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Tue, 26 May 2026 11:05:21 +0200 Subject: [PATCH 3/4] chore(ci): remove temporary bgp PR trigger --- .../workflows/release-brownfield-gradle-plugin.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/release-brownfield-gradle-plugin.yml b/.github/workflows/release-brownfield-gradle-plugin.yml index 7832ae0a..07bfed6f 100644 --- a/.github/workflows/release-brownfield-gradle-plugin.yml +++ b/.github/workflows/release-brownfield-gradle-plugin.yml @@ -4,17 +4,6 @@ on: push: tags: - 'brownfield-gradle-plugin/v*' - # Temporary pre-merge validation trigger for this PR. Remove after testing. - pull_request: - paths: - - '.github/workflows/release-brownfield-gradle-plugin.yml' - - 'jreleaser.yml' - - 'gradle-plugins/react/**' - - 'scripts/sync-brownfield-gradle-plugin-version.ts' - - 'scripts/generate-brownfield-gradle-plugin-release-notes.ts' - - 'scripts/__tests__/sync-brownfield-gradle-plugin-version.test.ts' - - 'scripts/__tests__/generate-brownfield-gradle-plugin-release-notes.test.ts' - - 'package.json' workflow_dispatch: inputs: ref: From 40b58e193a34552cc8efbb30dd8e40cfc295da58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Trzci=C5=84ski?= Date: Tue, 26 May 2026 13:36:39 +0200 Subject: [PATCH 4/4] fix(ci): correct manual bgp release dispatch --- .github/workflows/release-brownfield-gradle-plugin.yml | 2 +- docs/docs/docs/getting-started/android.mdx | 2 +- jreleaser.yml | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-brownfield-gradle-plugin.yml b/.github/workflows/release-brownfield-gradle-plugin.yml index 07bfed6f..f346e745 100644 --- a/.github/workflows/release-brownfield-gradle-plugin.yml +++ b/.github/workflows/release-brownfield-gradle-plugin.yml @@ -153,7 +153,7 @@ jobs: arguments: full-release env: JRELEASER_PROJECT_VERSION: ${{ needs.validate.outputs.version }} - JRELEASER_GITHUB_SKIP_TAG: ${{ needs.validate.outputs.skip_tag }} + JRELEASER_SKIP_TAG: ${{ needs.validate.outputs.skip_tag }} JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.JRELEASER_MAVENCENTRAL_USERNAME }} JRELEASER_MAVENCENTRAL_PASSWORD: ${{ secrets.JRELEASER_MAVENCENTRAL_PASSWORD }} diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index dab37ac1..fa0fec2e 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -35,7 +35,7 @@ buildscript { mavenCentral() } dependencies { - classpath("com.callstack.react:brownfield-gradle-plugin:0.6.2") + classpath("com.callstack.react:brownfield-gradle-plugin:") } } ``` diff --git a/jreleaser.yml b/jreleaser.yml index d241e8bd..b3a90cdc 100644 --- a/jreleaser.yml +++ b/jreleaser.yml @@ -15,7 +15,6 @@ release: releaseName: Brownfield Gradle Plugin {{projectVersion}} owner: callstack name: react-native-brownfield - skipTag: true overwrite: true changelog: external: out/jreleaser/brownfield-gradle-plugin-release-notes.md