diff --git a/.buildkite/commands/build-for-windows.ps1 b/.buildkite/commands/build-for-windows.ps1 index c2918b532f..82eff5b681 100644 --- a/.buildkite/commands/build-for-windows.ps1 +++ b/.buildkite/commands/build-for-windows.ps1 @@ -46,8 +46,6 @@ if ($BuildType -eq $BUILD_TYPE_DEV) { $env:IS_DEV_BUILD="true" } else { Write-Host "Preparing release build..." - node ./scripts/confirm-tag-matches-version.mjs - If ($LastExitCode -ne 0) { Exit $LastExitCode } } # Set architecture environment variable for AppX packaging diff --git a/.buildkite/commands/checkout-release-branch.sh b/.buildkite/commands/checkout-release-branch.sh new file mode 100755 index 0000000000..b15c5902e3 --- /dev/null +++ b/.buildkite/commands/checkout-release-branch.sh @@ -0,0 +1,25 @@ +#!/bin/bash -eu + +# Script to checkout a specific release branch. +# Usage: ./checkout-release-branch.sh +# +# Buildkite, by default, checks out a specific commit, ending up in a detached HEAD state. +# But in some cases, we need to ensure to be checked out on the `release/*` branch instead, namely: +# - When a `release-pipelines/*.yml` will end up needing to do a `git push` to the `release/*` branch (for version bumps) +# - When doing a new build from a job that was `pipeline upload`'d by such a pipeline, +# to ensure that the job doing the build would include that recent extra commit before starting the build. + +echo "--- :git: Checkout Release Branch" + +if [[ -n "${1:-}" ]]; then + RELEASE_VERSION="$1" +elif [[ "${BUILDKITE_BRANCH:-}" =~ ^release/ ]]; then + RELEASE_VERSION="${BUILDKITE_BRANCH#release/}" +else + echo "Error: RELEASE_VERSION parameter missing and BUILDKITE_BRANCH is not a release branch" + exit 1 +fi +BRANCH_NAME="release/${RELEASE_VERSION}" + +git fetch origin "$BRANCH_NAME" +git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME" diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index e0d75e03aa..117aa79c09 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -181,98 +181,3 @@ steps: notify: - github_commit_status: context: Distribute Dev Builds - - - group: šŸ“¦ Build for Mac - key: release-mac - steps: - - label: šŸ”Ø Mac Release Build - {{matrix}} - agents: - queue: mac - command: | - .buildkite/commands/prepare-environment.sh - - .buildkite/commands/install-node-dependencies.sh - node ./scripts/confirm-tag-matches-version.mjs - - echo "--- :node: Building Binary" - npm run make:macos-{{matrix}} - - # Local trial and error show this needs to run before the DMG generation (obviously) but after the binary has been built - echo "--- :hammer: Rebuild fs-attr if necessary before generating DMG" - case {{matrix}} in - x64) - echo "Rebuilding fs-xattr for {{matrix}} architecture" - npm rebuild fs-xattr --cpu universal - ;; - arm64) - echo "No need to rebuild fs-xattr because it works out of the box on Apple Silicon" - ;; - *) - echo "^^^ +++ Unexpected architecture {{matrix}}" - exit 1 - ;; - esac - - echo "--- :node: Packaging in DMG" - npm run make:dmg-{{matrix}} - - echo "--- šŸ“ƒ Notarizing Binary" - bundle exec fastlane notarize_binary - plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] - artifact_paths: - - apps/studio/out/**/*.app.zip - - apps/studio/out/*.dmg - matrix: - - x64 - - arm64 - notify: - - github_commit_status: - context: All Mac Release Builds - if: build.tag =~ /^v[0-9]+/ - - - group: šŸ“¦ Build for Windows - key: release-windows - steps: - - label: šŸ”Ø Windows Release Build - {{matrix}} - agents: - queue: windows - command: powershell -File .buildkite/commands/build-for-windows.ps1 -BuildType release -Architecture {{matrix}} - artifact_paths: - - apps\studio\out\**\studio-setup.exe - - apps\studio\out\**\studio-update.nupkg - - apps\studio\out\**\RELEASES - - apps\studio\out\**\*.appx - plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] - matrix: - - x64 - - arm64 - notify: - - github_commit_status: - context: All Windows Release Builds - if: build.tag =~ /^v[0-9]+/ - - - label: ":rocket: Publish Release Builds" - command: | - echo "--- :node: Downloading Binaries" - buildkite-agent artifact download "*.zip" . - buildkite-agent artifact download "*.dmg" . - buildkite-agent artifact download "*.exe" . - buildkite-agent artifact download "*.appx" . - buildkite-agent artifact download "*.nupkg" . - buildkite-agent artifact download "*\\RELEASES" . - - .buildkite/commands/install-node-dependencies.sh - - echo "--- :fastlane: Distributing Release Builds" - install_gems - bundle exec fastlane distribute_release_build - agents: - queue: mac - depends_on: - - step: release-mac - - step: release-windows - plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] - if: build.tag =~ /^v[0-9]+/ - notify: - - github_commit_status: - context: Publish Release Builds diff --git a/.buildkite/release-build-and-distribute.yml b/.buildkite/release-build-and-distribute.yml new file mode 100644 index 0000000000..f6fb6fde30 --- /dev/null +++ b/.buildkite/release-build-and-distribute.yml @@ -0,0 +1,115 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +# +# Release build and distribution pipeline. +# +# Triggered as a separate Buildkite build by the fastlane lanes (code_freeze, new_beta_release, +# finalize_release) via `buildkite_add_trigger_step` / `buildkite_trigger_build`. +# +# Expects `RELEASE_VERSION` env var to be set (e.g., '1.7.4'). +# Each step checks out the release branch to ensure it builds the latest commit. +--- + +# Used by mac agents only +env: + IMAGE_ID: $IMAGE_ID + +steps: + - group: šŸ“¦ Build for Mac + key: release-mac + steps: + - label: šŸ”Ø Mac Release Build - {{matrix}} + agents: + queue: mac + command: | + .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" + + .buildkite/commands/prepare-environment.sh + + .buildkite/commands/install-node-dependencies.sh + + echo "--- :node: Building Binary" + npm run make:macos-{{matrix}} + + # Local trial and error show this needs to run before the DMG generation (obviously) but after the binary has been built + echo "--- :hammer: Rebuild fs-attr if necessary before generating DMG" + case {{matrix}} in + x64) + echo "Rebuilding fs-xattr for {{matrix}} architecture" + npm rebuild fs-xattr --cpu universal + ;; + arm64) + echo "No need to rebuild fs-xattr because it works out of the box on Apple Silicon" + ;; + *) + echo "^^^ +++ Unexpected architecture {{matrix}}" + exit 1 + ;; + esac + + echo "--- :node: Packaging in DMG" + npm run make:dmg-{{matrix}} + + echo "--- šŸ“ƒ Notarizing Binary" + bundle exec fastlane notarize_binary + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + artifact_paths: + - apps/studio/out/**/*.app.zip + - apps/studio/out/*.dmg + matrix: + - x64 + - arm64 + notify: + - github_commit_status: + context: All Mac Release Builds + + - group: šŸ“¦ Build for Windows + key: release-windows + steps: + - label: šŸ”Ø Windows Release Build - {{matrix}} + agents: + queue: windows + command: | + bash .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" + powershell -File .buildkite/commands/build-for-windows.ps1 -BuildType release -Architecture {{matrix}} + artifact_paths: + - apps\studio\out\**\studio-setup.exe + - apps\studio\out\**\studio-update.nupkg + - apps\studio\out\**\RELEASES + - apps\studio\out\**\*.appx + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + matrix: + - x64 + - arm64 + notify: + - github_commit_status: + context: All Windows Release Builds + + - label: ":rocket: Publish Release Builds" + command: | + echo "--- :robot_face: Use bot for Git operations" + source use-bot-for-git + + .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" + + echo "--- :node: Downloading Binaries" + buildkite-agent artifact download "*.zip" . + buildkite-agent artifact download "*.dmg" . + buildkite-agent artifact download "*.exe" . + buildkite-agent artifact download "*.appx" . + buildkite-agent artifact download "*.nupkg" . + buildkite-agent artifact download "*\\RELEASES" . + + .buildkite/commands/install-node-dependencies.sh + + echo "--- :fastlane: Distributing Release Builds" + install_gems + bundle exec fastlane distribute_release_build + agents: + queue: mac + depends_on: + - step: release-mac + - step: release-windows + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + notify: + - github_commit_status: + context: Publish Release Builds diff --git a/.buildkite/release-pipelines/code-freeze.yml b/.buildkite/release-pipelines/code-freeze.yml new file mode 100644 index 0000000000..78ce67dd58 --- /dev/null +++ b/.buildkite/release-pipelines/code-freeze.yml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +--- + +env: + IMAGE_ID: $IMAGE_ID + +steps: + - label: ":snowflake: Code Freeze" + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + command: | + echo "--- :robot_face: Use bot for Git operations" + source use-bot-for-git + + echo "--- :npm: Install Node dependencies" + .buildkite/commands/install-node-dependencies.sh + + echo "--- :ruby: Setup Ruby Tools" + install_gems + + echo "--- :snowflake: Execute Code Freeze" + bundle exec fastlane code_freeze version:"${RELEASE_VERSION}" github_username:"${GITHUB_USERNAME}" skip_confirm:true + artifact_paths: + - i18n/bundle-strings.pot + agents: + queue: mac + retry: + manual: + reason: If release jobs fail, you should always re-trigger the task from Releases V2 rather than retrying the individual job from Buildkite + allowed: false diff --git a/.buildkite/release-pipelines/download-translations.yml b/.buildkite/release-pipelines/download-translations.yml new file mode 100644 index 0000000000..ba3cbc31e2 --- /dev/null +++ b/.buildkite/release-pipelines/download-translations.yml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +--- + +env: + IMAGE_ID: $IMAGE_ID + +steps: + - label: ":earth_asia: Download Translations" + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + command: | + echo "--- :robot_face: Use bot for Git operations" + source use-bot-for-git + + .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" + + echo "--- :ruby: Setup Ruby Tools" + install_gems + + echo "--- :earth_asia: Download Translations" + bundle exec fastlane download_translations github_username:"${GITHUB_USERNAME}" skip_confirm:true + agents: + queue: mac + retry: + manual: + reason: If release jobs fail, you should always re-trigger the task from Releases V2 rather than retrying the individual job from Buildkite + allowed: false diff --git a/.buildkite/release-pipelines/finalize-release.yml b/.buildkite/release-pipelines/finalize-release.yml new file mode 100644 index 0000000000..bc1df915c0 --- /dev/null +++ b/.buildkite/release-pipelines/finalize-release.yml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +--- + +env: + IMAGE_ID: $IMAGE_ID + +steps: + - label: ":checkered_flag: Finalize Release" + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + command: | + echo "--- :robot_face: Use bot for Git operations" + source use-bot-for-git + + .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" + + echo "--- :ruby: Setup Ruby Tools" + install_gems + + echo "--- :checkered_flag: Finalize Release" + bundle exec fastlane finalize_release version:"${RELEASE_VERSION}" skip_confirm:true + agents: + queue: mac + retry: + manual: + reason: If release jobs fail, you should always re-trigger the task from Releases V2 rather than retrying the individual job from Buildkite + allowed: false diff --git a/.buildkite/release-pipelines/new-beta-release.yml b/.buildkite/release-pipelines/new-beta-release.yml new file mode 100644 index 0000000000..e2043880ca --- /dev/null +++ b/.buildkite/release-pipelines/new-beta-release.yml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +--- + +env: + IMAGE_ID: $IMAGE_ID + +steps: + - label: ":package: New Beta Release" + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + command: | + echo "--- :robot_face: Use bot for Git operations" + source use-bot-for-git + + .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" + + echo "--- :ruby: Setup Ruby Tools" + install_gems + + echo "--- :package: Create New Beta" + bundle exec fastlane new_beta_release version:"${RELEASE_VERSION}" skip_confirm:true + agents: + queue: mac + retry: + manual: + reason: If release jobs fail, you should always re-trigger the task from Releases V2 rather than retrying the individual job from Buildkite + allowed: false diff --git a/.buildkite/release-pipelines/new-hotfix-release.yml b/.buildkite/release-pipelines/new-hotfix-release.yml new file mode 100644 index 0000000000..0eca77b136 --- /dev/null +++ b/.buildkite/release-pipelines/new-hotfix-release.yml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +--- + +env: + IMAGE_ID: $IMAGE_ID + +steps: + - label: ":fire: New Hotfix Release" + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + command: | + echo "--- :robot_face: Use bot for Git operations" + source use-bot-for-git + + echo "--- :ruby: Setup Ruby Tools" + install_gems + + echo "--- :fire: Create New Hotfix" + bundle exec fastlane new_hotfix_release version:"${RELEASE_VERSION}" skip_confirm:true + agents: + queue: mac + retry: + manual: + reason: If release jobs fail, you should always re-trigger the task from Releases V2 rather than retrying the individual job from Buildkite + allowed: false diff --git a/.buildkite/release-pipelines/publish-release.yml b/.buildkite/release-pipelines/publish-release.yml new file mode 100644 index 0000000000..cd6c18e61e --- /dev/null +++ b/.buildkite/release-pipelines/publish-release.yml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +--- + +env: + IMAGE_ID: $IMAGE_ID + +steps: + - label: ":shipit: Publish Release" + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + command: | + echo "--- :robot_face: Use bot for Git operations" + source use-bot-for-git + + .buildkite/commands/checkout-release-branch.sh "${RELEASE_VERSION}" + + echo "--- :ruby: Setup Ruby Tools" + install_gems + + echo "--- :shipit: Publish Release" + bundle exec fastlane publish_release version:"${RELEASE_VERSION}" github_username:"${GITHUB_USERNAME}" skip_confirm:true + agents: + queue: mac + retry: + manual: + reason: If release jobs fail, you should always re-trigger the task from Releases V2 rather than retrying the individual job from Buildkite + allowed: false diff --git a/.gitignore b/.gitignore index 410796ef76..43d9f0b44f 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,7 @@ wp-files/ vendor/* /fastlane/report.xml /fastlane/README.md +/fastlane/github_release_notes.txt # CLI npm artifacts cli/vendor/ diff --git a/AGENTS.md b/AGENTS.md index b2647b5518..df46f130ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,7 @@ For in-depth information, see these docs: - **CLI Design**: `docs/design-docs/cli.md` - CLI architecture, installation, IPC communication, data flow - **Custom Domains/SSL**: `docs/design-docs/custom-domains-and-ssl.md` - Proxy server, certificates, hosts file - **Localization**: `docs/localization.md` - GlotPress workflow, translation process -- **Release Process**: `docs/release-process.md` - Version tagging, Buildkite builds +- **Release Process**: `docs/release-process.md` - ReleasesV2 + Fastlane lifecycle, running lanes locally - **Overview**: `README.md` - Features, download links, contribution guidelines ## Quick Reference diff --git a/Gemfile b/Gemfile index 41fbc12b0d..bf8b0f62b4 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' gem 'fastlane', '~> 2.232' -gem 'fastlane-plugin-wpmreleasetoolkit', '~> 13.7' +gem 'fastlane-plugin-wpmreleasetoolkit', '~> 14.1' gem 'aws-sdk-cloudfront', '~> 1.87' diff --git a/Gemfile.lock b/Gemfile.lock index db0a46d515..ce5055fd4c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GEM ast (2.4.3) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1213.0) + aws-partitions (1.1218.0) aws-sdk-cloudfront (1.141.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) @@ -34,7 +34,7 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.121.0) + aws-sdk-kms (1.122.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.213.0) @@ -66,6 +66,7 @@ GEM dotenv (2.8.1) drb (2.2.3) emoji_regex (3.2.3) + erubi (1.13.1) excon (0.112.0) faraday (1.10.5) faraday-em_http (~> 1.0) @@ -96,7 +97,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.232.0) + fastlane (2.232.1) CFPropertyList (>= 2.3, < 4.0.0) abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) @@ -146,12 +147,13 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-plugin-wpmreleasetoolkit (13.8.1) + fastlane-plugin-wpmreleasetoolkit (14.1.0) activesupport (>= 6.1.7.1) buildkit (~> 1.5) chroma (= 0.2.0) diffy (~> 3.3) - fastlane (~> 2.213) + fastlane (~> 2.231) + gettext (~> 3.5) git (~> 1.3) google-cloud-storage (~> 1.31) java-properties (~> 0.3.0) @@ -165,11 +167,18 @@ GEM xcodeproj (~> 1.22) fastlane-sirp (1.0.0) sysrandom (~> 1.0) + forwardable (1.4.0) + gettext (3.5.1) + erubi + locale (>= 2.0.5) + prime + racc + text (>= 1.3.0) gh_inspector (1.1.3) git (1.19.1) addressable (~> 2.8) rchardet (~> 1.8) - google-apis-androidpublisher_v3 (0.95.0) + google-apis-androidpublisher_v3 (0.96.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -183,7 +192,7 @@ GEM google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.17.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.59.0) + google-apis-storage_v1 (0.61.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -221,10 +230,12 @@ GEM base64 language_server-protocol (3.17.0.5) lint_roller (1.1.0) + locale (2.1.4) logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (6.0.1) + minitest (6.0.2) + drb (~> 2.0) prism (~> 1.5) multi_json (1.19.1) multipart-post (2.4.1) @@ -261,6 +272,9 @@ GEM ast (~> 2.4.1) racc plist (3.7.2) + prime (0.1.4) + forwardable + singleton prism (1.9.0) progress_bar (1.3.4) highline (>= 1.6) @@ -277,7 +291,7 @@ GEM declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) + retriable (3.2.1) rexml (3.4.4) rouge (3.28.0) rubocop (1.84.1) @@ -310,10 +324,12 @@ GEM simctl (1.6.10) CFPropertyList naturally + singleton (0.3.0) sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) + text (1.3.1) trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.2) @@ -350,7 +366,7 @@ PLATFORMS DEPENDENCIES aws-sdk-cloudfront (~> 1.87) fastlane (~> 2.232) - fastlane-plugin-wpmreleasetoolkit (~> 13.7) + fastlane-plugin-wpmreleasetoolkit (~> 14.1) openssl rubocop (~> 1.42) diff --git a/docs/localization.md b/docs/localization.md index 0eaef6a14a..bf46ce4425 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,50 +1,36 @@ # Localization -Text is translated using [GlotPress](https://translate.wordpress.com) but the -process of getting original strings into GlotPress and the translations back -into the app is somewhat manual at the moment. +Text is translated using [GlotPress](https://translate.wordpress.com/projects/studio/). +The process of getting original strings into GlotPress and the translations back +into the app is fully automated as part of the release process. ## Supported Languages -We currently support the magnificent 16 languages defined in `common/lib/locale.ts`, -as well as Polish, Vietnamese and Ukrainian. +We currently support the magnificent 16 languages defined in `common/lib/locale.ts`, +as well as Polish, Vietnamese, Ukrainian and Hungarian. If you want to add support for another language you will need to add it to the -`supportedLocales` array. +`supportedLocales` array and add a corresponding `studio-.jed.json` file +in `tools/common/translations/`. ## Translation Process -### Extract and Import +### Extract and Import (automated) -#### Step 1: Extract Strings: +1. During **code freeze**, the `code_freeze` Fastlane lane extracts all translatable strings + and commits the resulting `i18n/bundle-strings.pot` file to the release branch. +2. A **wpcom cron job** (`import-github-originals.php`) periodically fetches the `.pot` file + from trunk (via the backmerge PR) and imports it into [GlotPress](https://translate.wordpress.com/projects/studio/). - 1. Run `npm run make-pot` to get the text out of the source files. +No manual steps are needed for string extraction or import. - This will remove the `out/pots/` directory and create a `*.pot` file for each module, as well as a bundle - of all translatable strings in `out/pots/bundle-strings.pot`. - It will also open the import page in your browser and select the `bundle-strings.pot` file. +### Export and Add (automated) -#### Step 2: Import to GlotPress: - - 1. Drag and drop the `out/pots/bundle-strings.pot` to the file input in the GlotPress Studio page https://translate.wordpress.com/projects/studio/import-originals/ - 2. Leave the format as "_Auto Detect_". - 3. Click **Import** and wait for the import to complete. - -### Export and Add - -#### Step 1: Export from GlotPress: - -We will export the translations as Jed-formatted JSON, which is a format -`@wordpress/i18n` can understand. It's ok if some translations are missing, +During **pre-release**, the `download_translations` Fastlane lane downloads translations +from GlotPress in Jed 1.x JSON format (which `@wordpress/i18n` understands) and creates +a PR to merge them into the release branch. It's ok if some translations are missing — they will be left as English in the app. - 1. Open [our project in GlotPress](https://translate.wordpress.com/projects/studio/). - 2. Click the **Project actions** menu. - 3. Click **Bulk Export**. - 4. Click **Select WP.Com Priority Languages** to only the magnificent 16 languages. - 5. Select **Polish**, **Vietnamese**, **Ukrainian** and **Hungarian** too. - 6. Change the format to `Jed 1.x (.json)`. - 7. Leave the other fields as default and click **Export**. - -#### Step 2: Add Translations to Project: - 1. Unzip the exported strings and add them to the `common/translations`. Overwrite - the files in there with your new files. +The lane discovers locales from the existing `studio-*.jed.json` files in +`tools/common/translations/` and downloads each one from GlotPress. + +No manual steps are needed for translation export. diff --git a/docs/release-process.md b/docs/release-process.md index 4975b4e082..841326306a 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,19 +1,82 @@ # Release Process -Release builds are built, signed, notarized, and uploaded to the CDN by [Buildkite](https://buildkite.com/automattic/studio). -Once the release is on the CDN the auto-update process will start downloading the new version. +Releases are managed through [ReleasesV2](https://releases.a8c.com/) and automated via Fastlane + Buildkite. Each step below has a corresponding button in the ReleasesV2 UI that triggers a Buildkite pipeline, which runs the appropriate Fastlane lane. -## Creating a Release +Builds are signed, notarized (macOS), and uploaded to the Apps CDN automatically. Once on the CDN, auto-update delivers the new version to users. -These instructions are for creating version 0.1.0-alpha5, but the steps are the same for releases with no pre-release tag. +## Release Lifecycle -1. Create a PR which updates the `version` field in `package.json` to `'0.1.0-alpha5'`. - - Remember to run `npm install` so the version in `package-lock.json` gets updated too. -2. Merge this PR. -3. Make a note of the commit hash of the PR which was just merged into `trunk`, e.g. `a1c70f3a3be5d28922a48f7f298f6152d6001516` -4. Tag this commit with `v0.1.0-alpha5`: - 1. On your local machine get the latest code: `git checkout trunk && git pull` - 2. Create the tag: `git tag v0.1.0-alpha5 a1c70f3a3be5d28922a48f7f298f6152d6001516` - 3. Push the tag to the GitHub repo: `git push origin v0.1.0-alpha5` +### 1. Code Freeze -Pushing the tag will automatically start the build and release process, and is complete when the build finishes cleanly. +**ReleasesV2 milestone**: Code Freeze | **Fastlane lane**: `code_freeze` + +- Creates `release/` branch from `trunk` +- Extracts translatable strings and commits them to the release branch (a wpcom cron imports them to GlotPress via a backmerge PR) +- Generates draft release notes from merged PRs and commits them to the release branch +- Creates a backmerge PR from the release branch into `trunk` +- Bumps version to `-beta1` and triggers a build (the build tags and uploads to CDN) + +### 2. Beta Releases + +**ReleasesV2 milestone**: Beta Release | **Fastlane lane**: `new_beta_release` + +- Increments the beta number (e.g. beta1 → beta2) +- Triggers a build (the build tags the new version and uploads to CDN) +- Repeat as needed for additional betas + +### 3. Pre-Release + +**ReleasesV2 milestone**: Pre-Release + +- **Download translations**: Button triggers `download_translations` lane, which fetches translations from GlotPress and commits them to the release branch +- **Release notes**: Review and refine the draft release notes in `RELEASE-NOTES.txt` on the `release/` branch (a draft is auto-generated during code freeze) +- **Smoke tests**: Verify betas on macOS and Windows + +### 4. Finalize Release + +**ReleasesV2 milestone**: Release | **Fastlane lane**: `finalize_release` + +- Removes beta suffix (sets version to ``) +- Triggers the final release build, which uploads to the Apps CDN, creates a **draft** GitHub release with notes and download links, and notifies Slack + +### 5. Publish Release + +**Fastlane lane**: `publish_release` + +- Publishes the draft GitHub release (which creates the corresponding GitHub tag too) +- Creates a backmerge PR from `release/` into `trunk` + +### 6. Post-Release (manual) + +- Publish Windows build to the Microsoft Store +- Update Slack channel bookmark +- Notify team for changelog update +- Notify next Release Wrangler + +## Hotfix Releases + +**Fastlane lane**: `new_hotfix_release` + +- Creates a `release/` branch from the latest release tag (or existing release branch) +- Bumps the version number +- After committing fixes, use `finalize_release` and `publish_release` as normal + +## Running Lanes Locally + +Lanes can be run locally for testing (requires Ruby + Bundler + GITHUB_TOKEN + BUILDKITE_TOKEN): + +```sh + +# Running these lanes locally will always print a description of what the lane will do with a confirmation prompt +bundle exec fastlane code_freeze version:"1.8.0" +bundle exec fastlane new_beta_release version:"1.8.0" +bundle exec fastlane finalize_release version:"1.8.0" +bundle exec fastlane publish_release version:"1.8.0" +``` + +## Reference + +- [Buildkite pipelines](https://buildkite.com/automattic/studio) +- [ReleasesV2 scenarios](https://releases.a8c.com/) +- [Fastfile](../fastlane/Fastfile) — all lane implementations +- [Localization](localization.md) — string extraction and translation workflow diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 05796bf74e..edb4075c8f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -4,6 +4,8 @@ fastlane_require 'digest' fastlane_require 'zip' fastlane_require 'aws-sdk-cloudfront' fastlane_require 'json' +fastlane_require 'net/http' +fastlane_require 'uri' UI.user_error!('Please run fastlane via `bundle exec`') unless FastlaneCore::Helper.bundler? @@ -18,11 +20,18 @@ BUILDS_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'apps', 'studio', 'out') DRY_RUN = ENV['DRY_RUN'] == 'true' # Make sure the WPCOM API token is set -UI.user_error!("Environment variable WPCOM_API_TOKEN is not set") if ENV['WPCOM_API_TOKEN'].nil? && !DRY_RUN +UI.user_error!('Environment variable WPCOM_API_TOKEN is not set') if ENV['WPCOM_API_TOKEN'].nil? && !DRY_RUN UI.message("Running in #{DRY_RUN ? 'DRY RUN' : 'NORMAL'} mode") -# Read version from package.json -PACKAGE_VERSION = JSON.parse(File.read(File.join(PROJECT_ROOT_FOLDER, 'package.json')))['version'] +# Path to the Studio app's package.json (monorepo workspace) +PACKAGE_JSON_PATH = File.join(PROJECT_ROOT_FOLDER, 'apps', 'studio', 'package.json') + +# Path to the translatable strings file (committed to the repo for GlotPress import) +POT_FILE_PATH = File.join(PROJECT_ROOT_FOLDER, 'i18n', 'bundle-strings.pot') + +# GlotPress project URL and output directory for Studio UI translations (Jed 1.x JSON) +GLOTPRESS_PROJECT_URL = 'https://translate.wordpress.com/projects/studio' +TRANSLATIONS_DIR = File.join(PROJECT_ROOT_FOLDER, 'tools', 'common', 'translations') APPLE_TEAM_ID = 'PZYM8XX95Q' APPLE_BUNDLE_IDENTIFIER = 'com.automattic.studio' @@ -31,6 +40,11 @@ APPLE_API_KEY_PATH = File.join(SECRETS_FOLDER, 'app_store_connect_fastlane_api_k # Site ID for WordPress.com Studio in the Apps CDN WPCOM_STUDIO_SITE_ID = '239164481' +GITHUB_REPO = 'Automattic/studio' +MAIN_BRANCH = 'trunk' +BUILDKITE_ORG = 'automattic' +BUILDKITE_PIPELINE = 'studio' + # Use this instead of getting values from ENV directly # It will throw an error if the requested value is missing def get_required_env(key) @@ -81,9 +95,12 @@ lane :distribute_dev_build do |_options| distribute_builds end -desc 'Ship release build' -lane :distribute_release_build do |_options| - release_tag = get_required_env('BUILDKITE_TAG') +# Upload release builds to the Apps CDN and notify Slack. +# +# @param version [String] The release version (e.g., '1.7.4' or '1.7.4-beta1') +# +lane :distribute_release_build do |version: read_package_json_version| + release_tag = "v#{version}" builds = distribute_builds(release_tag:) if DRY_RUN @@ -92,6 +109,15 @@ lane :distribute_release_build do |_options| next end + # Create the tag or draft GitHub release only after the build succeeds. + # This avoids orphan tags or draft releases if the build fails. + if version.include?('beta') + add_git_tag(tag: release_tag) + push_git_tags(tag: release_tag) + else + create_draft_github_release(version: version, release_tag: release_tag, builds: builds) + end + slack( username: 'CI Bot', icon_url: 'https://octodex.github.com/images/jenktocat.jpg', @@ -106,19 +132,329 @@ lane :distribute_release_build do |_options| ) end +######################################################################## +# Release Management Lanes +######################################################################## + +# Create a new release branch from trunk and the first beta. +# +# - Extracts translatable strings and commits them to the release branch (for GlotPress import) +# - Creates a `release/` branch from trunk +# - Delegates to {new_beta_release} to bump to beta1, commit, push, tag, and trigger a build +# +# @param version [String] The version to freeze (e.g., '1.7.4') +# @param skip_confirm [Boolean] Skip interactive confirmation prompts (default: false) +# +lane :code_freeze do |version:, skip_confirm: false, github_username: nil| + branch_name = "release/#{version}" + + UI.important <<~PROMPT + Freezing code for release #{version}. This will: + - Create a new `#{branch_name}` branch from `#{MAIN_BRANCH}` + - Extract translatable strings and commit to `#{branch_name}` + - Generate draft release notes from merged PRs + - Create a backmerge PR from `#{branch_name}` to `#{MAIN_BRANCH}` + - Trigger the first beta build for all platforms (macOS, Windows), which will then: + - Upload build artifacts to the Apps CDN + - Notify #dotcom-studio on Slack + PROMPT + next unless skip_confirm || UI.confirm('Continue?') + + Fastlane::Helper::GitHelper.checkout_and_pull(MAIN_BRANCH) + + # Create release branch from MAIN_BRANCH + Fastlane::Helper::GitHelper.create_branch(branch_name) + + # Fastlane sh() runs from the fastlane/ directory, so use absolute paths for shell commands + pot_dir = File.join(PROJECT_ROOT_FOLDER, 'out', 'pots') + + sh('rm', '-rf', pot_dir) + sh('mkdir', '-p', File.dirname(POT_FILE_PATH)) + sh( + 'npx', 'wp-babel-makepot', + "#{PROJECT_ROOT_FOLDER}/{apps/studio/src,apps/cli,tools/common}/**/*.{js,jsx,ts,tsx}", + '--ignore', "#{PROJECT_ROOT_FOLDER}/apps/cli/node_modules/**/*,**/*.d.ts", + '--base', PROJECT_ROOT_FOLDER, + '--dir', pot_dir, + '--output', POT_FILE_PATH + ) + + # Commit and push the .pot file so the wpcom cron can import it to GlotPress + git_add(path: [POT_FILE_PATH]) + git_commit( + path: [POT_FILE_PATH], + message: "[skip ci] Code freeze: Update translatable strings for #{version}", + allow_nothing_to_commit: true + ) + + # Generate draft release notes from PRs merged since the last published release + update_release_notes_draft(version: version) + git_add(path: ['RELEASE-NOTES.txt']) + git_commit( + path: ['RELEASE-NOTES.txt'], + message: "[skip ci] Code freeze: Add draft release notes for #{version}", + allow_nothing_to_commit: true + ) + + push_to_git_remote(set_upstream: true) + + # Create backmerge PR from the release branch to MAIN_BRANCH so that the strings bundle is uploaded to GlotPress + create_backmerge_pr(source_branch: branch_name, github_username: github_username) + + # Create the first beta (bumps version, commits, pushes, tags, triggers build) + new_beta_release(version: version, skip_confirm: skip_confirm) + + UI.success("Code freeze complete! Created #{branch_name} with first beta") +end + +# Create a new beta release on the current release branch. +# +# - Determines the next beta number automatically from package.json +# - Bumps the version, commits, tags, and triggers a release build +# +# @param version [String] The base release version (e.g., '1.7.4') +# @param skip_confirm [Boolean] Skip interactive confirmation prompts (default: false) +# +lane :new_beta_release do |version:, skip_confirm: false| + current_version = read_package_json_version + current_beta = current_version[/beta(\d+)$/, 1].to_i + + # Guard against version mismatch (e.g. package.json has 1.7.4-beta3 but version param is 1.8.0) + # Skip the check when current_beta is 0, meaning this is the first beta (called from code_freeze before any bump). + if current_beta.positive? + base_version = current_version.sub(/-beta\d+$/, '') + UI.user_error!("Version mismatch: package.json has #{current_version} but expected #{version}-betaN") unless base_version == version + end + + new_version = "#{version}-beta#{current_beta + 1}" + + UI.important <<~PROMPT + Creating new beta release #{new_version}. This will: + - Bump version to #{new_version} + - Trigger a release build for all platforms (macOS, Windows), which will then: + - Upload build artifacts to the Apps CDN + - Tag v#{new_version} + - Notify #dotcom-studio on Slack + PROMPT + next unless skip_confirm || UI.confirm('Continue?') + + bump_version_commit_and_push(version: new_version) + + trigger_release_build(version: new_version) + + UI.success("New beta release created: v#{new_version}") +end + +# Finalize the release by removing the beta suffix and triggering the final build. +# +# - Bumps version to the final release number (removes beta suffix) +# - Triggers a release build in Buildkite +# - The build creates a draft GitHub release (with notes and download links) after uploading to CDN +# - The draft is published later by the {publish_release} lane +# +# @param version [String] The final version number (e.g., '1.7.4') +# @param skip_confirm [Boolean] Skip interactive confirmation prompts (default: false) +# +lane :finalize_release do |version:, skip_confirm: false| + UI.important <<~PROMPT + Finalizing release #{version}. This will: + - Bump version to #{version} (remove beta suffix) + - Trigger a release build for all platforms (macOS, Windows), which will then: + - Upload build artifacts to the Apps CDN + - Create a draft GitHub release with release notes and download links + - Notify #dotcom-studio on Slack + PROMPT + next unless skip_confirm || UI.confirm('Continue?') + + bump_version_commit_and_push(version: version) + + trigger_release_build(version: version) + + UI.success("Release finalized: v#{version} (build triggered)") +end + +# Publish the release by making the draft GitHub release public and creating a backmerge PR. +# +# - Publishes the draft GitHub release created by {distribute_release_build} +# - Creates a backmerge PR from the release branch to trunk (with intermediate branch for conflicts) +# +# @param version [String] The version to publish (e.g., '1.7.4') +# @param skip_confirm [Boolean] Skip interactive confirmation prompts (default: false) +# +lane :publish_release do |version:, skip_confirm: false, github_username: nil| + release_branch = "release/#{version}" + + UI.important <<~PROMPT + Publishing release #{version}. This will: + - Publish the draft GitHub release for v#{version} + - Create a backmerge PR from `#{release_branch}` into `#{MAIN_BRANCH}` + - Delete the `#{release_branch}` branch after creating the backmerge PR + PROMPT + next unless skip_confirm || UI.confirm('Continue?') + + # Publish the draft GitHub release + publish_github_release( + repository: GITHUB_REPO, + name: "v#{version}" + ) + + # Create backmerge PR with intermediate branch to handle conflicts + create_backmerge_pr(source_branch: release_branch, github_username: github_username) + + Fastlane::Helper::GitHelper.delete_remote_branch_if_exists!(release_branch) + Fastlane::Helper::GitHelper.checkout_and_pull(MAIN_BRANCH) + Fastlane::Helper::GitHelper.delete_local_branch_if_exists!(release_branch) + + UI.success("Release v#{version} published!") +end + +# Create a hotfix release branch from the latest release tag. +# +# - Finds the latest non-beta release tag +# - Creates a `release/` branch from that tag +# - Bumps the version number +# +# @param version [String] The hotfix version number (e.g., '1.7.5') +# @param skip_confirm [Boolean] Skip interactive confirmation prompts (default: false) +# +lane :new_hotfix_release do |version:, skip_confirm: false| + branch_name = "release/#{version}" + + # Extract the major.minor version to find the previous release tag + match_data = version.match(/^(\d+\.\d+)/) + UI.user_error!("Invalid version '#{version}'. Expected format like '1.7.5' so that a 'major.minor' (e.g., '1.7') can be extracted.") if match_data.nil? + short_version = match_data[1] + previous_tag = find_previous_tag(pattern: "v#{short_version}.*") + + # Determine the base for the hotfix branch: either a tag or a release branch + previous_release_branch = "release/#{short_version}" + base_ref = if previous_tag && !previous_tag.include?('beta') + previous_tag + elsif Fastlane::Helper::GitHelper.branch_exists_on_remote?(branch_name: previous_release_branch) + UI.message("Tag for version '#{short_version}' not found. Using release branch '#{previous_release_branch}' as the base for the hotfix.") + previous_release_branch + else + UI.user_error!("Neither a tag for version '#{short_version}' nor branch '#{previous_release_branch}' exists on the remote. A hotfix branch cannot be created.") + end + + UI.important <<~PROMPT + Creating hotfix release #{version}. This will: + - Create a new `#{branch_name}` branch from #{base_ref} + - Bump version to #{version} + PROMPT + next unless skip_confirm || UI.confirm('Continue?') + + Fastlane::Helper::GitHelper.create_branch(branch_name, from: base_ref) + + bump_version_commit_and_push(version: version, set_upstream: true) + + UI.success("Hotfix branch #{branch_name} created from #{base_ref}") +end + +# Download Studio UI translations from GlotPress (Jed 1.x JSON) into tools/common/translations/. +# Discovers locales from existing files in TRANSLATIONS_DIR (e.g. studio-es.jed.json → "es"). +# +lane :fetch_glotpress_translations do + files = Dir.glob(File.join(TRANSLATIONS_DIR, 'studio-*.jed.json')) + locales = files.map { |f| File.basename(f, '.jed.json').delete_prefix('studio-') }.sort + UI.user_error!('No existing translation files found in translations directory') if locales.empty? + + UI.message("Downloading translations for #{locales.length} locales from GlotPress...") + + locales.each do |locale| + url = URI("#{GLOTPRESS_PROJECT_URL}/#{locale}/default/export-translations/?format=jed1x") + response = Net::HTTP.get_response(url) + UI.user_error!("Failed to download translations for '#{locale}': HTTP #{response.code}") unless response.is_a?(Net::HTTPSuccess) + + output_path = File.join(TRANSLATIONS_DIR, "studio-#{locale}.jed.json") + File.write(output_path, response.body) + UI.message(" Downloaded #{locale}") + end + + UI.success("Downloaded translations for #{locales.length} locales") +end + +# Download the latest translations and create a PR to merge them into the release branch. +# +# @param skip_confirm [Boolean] Skip interactive confirmation prompts (default: false) +# +lane :download_translations do |skip_confirm: false, github_username: nil| + current_branch = Fastlane::Helper::GitHelper.current_git_branch + + UI.important <<~PROMPT + Downloading translations. This will: + - Download latest translations from GlotPress + - Create a PR to merge them into `#{current_branch}` + PROMPT + next unless skip_confirm || UI.confirm('Continue?') + + fetch_glotpress_translations + + translations_branch = 'update/latest-translations' + Fastlane::Helper::GitHelper.delete_local_branch_if_exists!(translations_branch) + Fastlane::Helper::GitHelper.create_branch(translations_branch) + + git_add(path: ['.']) + result = git_commit( + path: ['.'], + message: 'Update translations', + allow_nothing_to_commit: true + ) + + if result.nil? + Fastlane::Helper::GitHelper.checkout_and_pull(current_branch) + Fastlane::Helper::GitHelper.delete_local_branch_if_exists!(translations_branch) + UI.important('No translation changes detected.') + + if is_ci? + buildkite_annotate( + context: 'download-translations', + style: 'info', + message: 'No translation changes detected. No PR was created.' + ) + end + next + end + + Fastlane::Helper::GitHelper.delete_remote_branch_if_exists!(translations_branch) + push_to_git_remote(set_upstream: true, tags: false) + + pr_url = create_pull_request( + repo: GITHUB_REPO, + title: 'Update translations', + body: 'Merges the latest translations from GlotPress.', + labels: 'Releases', + base: current_branch, + head: translations_branch, + reviewers: Array(github_username) + ) + + if is_ci? && pr_url + buildkite_annotate( + context: 'download-translations', + style: 'info', + message: "Translations Pull Request: #{pr_url}" + ) + end +end + +######################################################################## +# Build and Distribution Helper Methods +######################################################################## + def get_windows_update_release_sha(arch: 'x64') releases_file_path = File.join(BUILDS_FOLDER, 'make', 'squirrel.windows', arch, 'RELEASES') begin releases_content = File.read(releases_file_path) - rescue => error + rescue StandardError UI.user_error!("Couldn't read RELEASES file of Windows build at #{releases_file_path}. Please ensure that the file compute the release SHA1.") end match_data = releases_content.match(/([a-zA-Z\d]{40})\s(.*\.nupkg)\s(\d+)/) - UI.user_error!("Could not parse Windows RELEASES file format") unless match_data + UI.user_error!('Could not parse Windows RELEASES file format') unless match_data - sha1, filename, size = match_data.captures + sha1, = match_data.captures sha1 end @@ -127,15 +463,14 @@ def distribute_builds( build_number: get_required_env('BUILDKITE_BUILD_NUMBER'), release_tag: nil ) - build_type = if release_tag.nil? - 'Nightly' - elsif release_tag.downcase.include?('beta') - 'Beta' - else - 'Production' - end - version = release_tag.nil? ? "v#{PACKAGE_VERSION}" : release_tag + 'Nightly' + elsif release_tag.downcase.include?('beta') + 'Beta' + else + 'Production' + end + version = release_tag.nil? ? "v#{read_package_json_version}" : release_tag appx_version = "#{version[/\d+\.\d+\.\d+/]}.0" release_notes = release_tag.nil? ? "Development build #{version}-#{build_number}" : "Release #{release_tag}" @@ -157,7 +492,7 @@ def distribute_builds( sha: commit_hash }, windows_update: { - binary_path: File.join(BUILDS_FOLDER, 'make', 'squirrel.windows', 'x64', "studio-update.nupkg"), + binary_path: File.join(BUILDS_FOLDER, 'make', 'squirrel.windows', 'x64', 'studio-update.nupkg'), name: 'Windows - x64 Update', platform: 'Windows - x64', arch: 'x64', @@ -165,7 +500,7 @@ def distribute_builds( sha: get_windows_update_release_sha(arch: 'x64') }, windows_arm64_update: { - binary_path: File.join(BUILDS_FOLDER, 'make', 'squirrel.windows', 'arm64', "studio-update.nupkg"), + binary_path: File.join(BUILDS_FOLDER, 'make', 'squirrel.windows', 'arm64', 'studio-update.nupkg'), name: 'Windows - ARM64 Update', platform: 'Windows - ARM64', arch: 'arm64', @@ -187,7 +522,7 @@ def distribute_builds( name: 'Mac Apple Silicon (DMG)', platform: 'Mac - Silicon', arch: 'arm64', - install_type: 'Full Install', + install_type: 'Full Install' }, windows: { binary_path: File.join(BUILDS_FOLDER, 'make', 'squirrel.windows', 'x64', 'studio-setup.exe'), @@ -196,7 +531,7 @@ def distribute_builds( arch: 'x64', install_type: 'Full Install' }, - windows_arm64: { + windows_arm64: { binary_path: File.join(BUILDS_FOLDER, 'make', 'squirrel.windows', 'arm64', 'studio-setup.exe'), name: 'Windows - ARM64', platform: 'Windows - ARM64', @@ -208,15 +543,15 @@ def distribute_builds( name: 'Windows - x64 (Unsigned Appx)', platform: 'Microsoft Store - x64', arch: 'x64', - install_type: 'Full Install', + install_type: 'Full Install' }, windows_arm64_appx_unsigned: { binary_path: File.join(BUILDS_FOLDER, 'Studio-appx-arm64-unsigned', "Studio #{appx_version} unsigned.appx"), name: 'Windows - ARM64 (Unsigned Appx)', platform: 'Microsoft Store - ARM64', arch: 'arm64', - install_type: 'Full Install', - }, + install_type: 'Full Install' + } } builds_to_upload = release_tag.nil? ? update_builds : { **update_builds, **full_install_builds } @@ -270,11 +605,11 @@ def create_versioned_file(original_file_path:, version:, arch:) end base_name = File.basename(original_filename, extension) - if base_name.match(/-#{arch}/i) - versioned_filename = "#{base_name}-#{version}#{extension}" - else - versioned_filename = "#{base_name}-#{arch}-#{version}#{extension}" - end + versioned_filename = if base_name.match(/-#{arch}/i) + "#{base_name}-#{version}#{extension}" + else + "#{base_name}-#{arch}-#{version}#{extension}" + end versioned_file_path = File.join(File.dirname(original_file_path), versioned_filename) UI.message("Copying #{original_file_path} to #{versioned_file_path}") @@ -283,19 +618,17 @@ def create_versioned_file(original_file_path:, version:, arch:) end # Helper method to upload a file to Apps CDN with dry run support -def upload_file_to_apps_cdn(site_id:, product:, file_path:, platform:, arch:, build_type:, install_type: 'Full Install', visibility:, version:, build_number:, release_notes:, sha: nil, error_on_duplicate:, dry_run: DRY_RUN) +def upload_file_to_apps_cdn(site_id:, product:, file_path:, platform:, arch:, build_type:, visibility:, version:, build_number:, release_notes:, error_on_duplicate:, install_type: 'Full Install', sha: nil, dry_run: DRY_RUN) versioned_file_path = create_versioned_file(original_file_path: file_path, version:, arch:) - if !File.exist?(versioned_file_path) - UI.user_error!("File #{versioned_file_path} does not exist") - end + UI.user_error!("File #{versioned_file_path} does not exist") unless File.exist?(versioned_file_path) if dry_run media_url = "https://appscdn.wordpress.com/downloads/wordpress-com-studio/#{platform}/#{version}/#{build_number}" - # Check if the file exists - UI.message("[DRY RUN] Upload step skipped due to dry run mode.") + # Check if the file exists + UI.message('[DRY RUN] Upload step skipped due to dry run mode.') UI.message(" File exists at: #{versioned_file_path}") - UI.message(" file size: #{File.size(versioned_file_path)/1024/1024} MB") + UI.message(" file size: #{File.size(versioned_file_path) / 1024 / 1024} MB") UI.message(" for platform: #{platform}") UI.message(" media url: #{media_url}") UI.message(" build type: #{build_type}") @@ -329,8 +662,176 @@ def upload_file_to_apps_cdn(site_id:, product:, file_path:, platform:, arch:, bu error_on_duplicate: ) - UI.message("--------------------------------") + UI.message('--------------------------------') UI.message("āœ… Uploaded file: #{versioned_file_path} to #{result[:media_url]}") result end + +######################################################################## +# Release Management Helper Methods +######################################################################## + +# Bump version in package.json, commit, and push. +# Used by new_beta_release, finalize_release, and new_hotfix_release. +def bump_version_commit_and_push(version:, set_upstream: false) + set_package_json_version(version: version) + git_add(path: ['apps/studio/package.json', 'package-lock.json']) + git_commit( + path: ['apps/studio/package.json', 'package-lock.json'], + message: "[skip ci] Bump version to #{version}" + ) + push_to_git_remote(set_upstream: set_upstream) +end + +# Read the current version from package.json +def read_package_json_version + JSON.parse(File.read(PACKAGE_JSON_PATH))['version'] +end + +# Set the version in apps/studio/package.json (and update root package-lock.json via npm workspaces) +def set_package_json_version(version:) + sh('npm', 'version', version, '--no-git-tag-version', '-w', 'studio-app', '--prefix', PROJECT_ROOT_FOLDER) +end + +# Extract release notes for a specific version from RELEASE-NOTES.txt +def extract_release_notes(version:) + notes_path = File.join(PROJECT_ROOT_FOLDER, 'RELEASE-NOTES.txt') + content = File.read(notes_path) + + escaped = Regexp.escape(version) + match = content.match(/^#{escaped}\n=+\n(.*?)(?=^\d+\.\d+\S*\n=+|\z)/m) + + if match + match[1].strip + else + UI.important("No release notes found for #{version} in RELEASE-NOTES.txt") + "Release #{version}" + end +end + +# Generate a draft release notes section from merged PRs and prepend it to RELEASE-NOTES.txt. +# Uses GitHub's generate-notes API to list PRs since the last published release. +def update_release_notes_draft(version:) + # Find the latest stable (non-beta) release tag reachable from HEAD. + previous_tag = find_previous_tag(pattern: 'v*', exclude: '*beta*') + + changelog = get_prs_between_tags( + repository: GITHUB_REPO, + tag_name: "v#{version}", + previous_tag: previous_tag, + target_commitish: 'HEAD' + ) + + # Convert "* Title by @user in https://github.com/Org/Repo/pull/123" to "* Title #123" + lines = changelog.lines.reject { |l| l.start_with?('##') || l.strip.empty? } + formatted = lines.map do |line| + line.sub(%r{ by @\S+ in https://github\.com/\S+/pull/(\d+)}, ' #\1') + end.join.strip + + if formatted.empty? + UI.important('No PRs found for draft release notes — skipping RELEASE-NOTES.txt update') + return + end + + notes_path = File.join(PROJECT_ROOT_FOLDER, 'RELEASE-NOTES.txt') + content = File.read(notes_path) + + new_section = "#{version}\n=====\n#{formatted}\n" + + # Insert the new version section after the "Unreleased" header + unless content.sub!(/^(Unreleased\n=+\n)\n*/, "\\1\n#{new_section}\n") + UI.user_error!("Could not find 'Unreleased' header in RELEASE-NOTES.txt. Please ensure the file has an 'Unreleased' section.") + end + File.write(notes_path, content) + + UI.success("Draft release notes for #{version} added to RELEASE-NOTES.txt") +end + +# Create a backmerge PR from the given source branch into the main branch +# and post a Buildkite annotation with links to the created PRs. +def create_backmerge_pr(source_branch:, github_username: nil) + created_prs = create_release_backmerge_pull_request( + repository: GITHUB_REPO, + source_branch: source_branch, + default_branch: MAIN_BRANCH, + labels: ['Releases'], + reviewers: Array(github_username) + ) + + return unless is_ci? && created_prs && !created_prs.empty? + + buildkite_annotate( + context: 'backmerge-prs', + style: 'info', + message: "Backmerge Pull Requests:\n#{created_prs.map { |pr| "* #{pr}\n" }.join}" + ) +end + +# Create a draft GitHub release with release notes and download links. +# Called from distribute_release_build after a successful final (non-beta) build. +def create_draft_github_release(version:, release_tag:, builds:) + notes = extract_release_notes(version: version) + + # Format download links from CDN URLs + download_link_keys = %i[x64_dmg arm64_dmg windows windows_arm64] + links = download_link_keys.filter_map do |key| + build = builds[key] + next unless build&.dig(:cdn_url) + + label = build[:name].sub(' - ', ' \u2013 ') # Use en-dash like existing releases + "[#{label}](#{build[:cdn_url]})" + end + + body = notes + unless links.empty? + body += "\n\n---\nDownload Studio #{version}: #{links.join(', ')}\n\n" \ + 'The latest version is always available on the [WordPress Studio](https://developer.wordpress.com/studio/) site.' + end + + release_notes_path = File.join(PROJECT_ROOT_FOLDER, 'fastlane', 'github_release_notes.txt') + File.write(release_notes_path, body) + + base_version = version.sub(/-beta\d+$/, '') + create_github_release( + repository: GITHUB_REPO, + version: release_tag, + target: "release/#{base_version}", + release_notes_file_path: release_notes_path, + release_assets: [], + prerelease: false, + is_draft: true + ) + + UI.success("Created draft GitHub release #{release_tag} with download links") +end + +# Trigger a release build in Buildkite for the given version. +# +# Uses `buildkite_add_trigger_step` on CI (to create a separate build with proper Git mirroring) +# and `buildkite_trigger_build` when running locally. +def trigger_release_build(version:) + base_version = version.sub(/-beta\d+$/, '') + branch = "release/#{base_version}" + + environment = { + 'RELEASE_VERSION' => base_version + } + + common_args = { + pipeline_file: 'release-build-and-distribute.yml', + branch: branch, + message: "Release Build (v#{version})", + environment: environment + } + + if is_ci? + buildkite_add_trigger_step(**common_args) + else + buildkite_trigger_build( + buildkite_organization: BUILDKITE_ORG, + buildkite_pipeline: BUILDKITE_PIPELINE, + **common_args + ) + end +end diff --git a/package.json b/package.json index da105172d0..5396f32a9a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "test:watch": "vitest", "e2e": "npx playwright install && npx playwright test", "test:metrics": "npx playwright test --config=./tools/metrics/playwright.metrics.config.ts", - "make-pot": "node ./scripts/make-pot.mjs", "download-language-packs": "ts-node ./scripts/download-language-packs.ts" }, "devDependencies": { diff --git a/scripts/confirm-tag-matches-version.mjs b/scripts/confirm-tag-matches-version.mjs deleted file mode 100644 index 8550c8dc96..0000000000 --- a/scripts/confirm-tag-matches-version.mjs +++ /dev/null @@ -1,26 +0,0 @@ -// Fails (thus halting the build) if the git tag doesn't match the version in package.json. -// This safety measure is part of the release build process. - -import packageJson from '../apps/studio/package.json' with { type: 'json' }; - -const tagTriggeringBuild = process.env.BUILDKITE_TAG; - -if ( ! tagTriggeringBuild ) { - // Are you trying to dev on the build scripts outside of CI? - // You will need to define the BUILDKITE_TAG environment variable before - // running this script. e.g. - // BUILDKITE_TAG=v1.2.3 node ./scripts/confirm-tag-matches-version.mjs - throw new Error( 'Build was not triggered by a new tag' ); -} - -if ( tagTriggeringBuild === packageJson.version ) { - throw new Error( 'The git tag used to trigger a release build must start with "v"' ); -} - -if ( tagTriggeringBuild !== 'v' + packageJson.version ) { - throw new Error( - `Tag which triggered the build (${ tagTriggeringBuild }) does not match version in package.json (${ packageJson.version })` - ); -} - -process.exit( 0 ); diff --git a/scripts/download-available-site-translations.mjs b/scripts/download-available-site-translations.mjs index 0de9b0c144..05f20e3863 100644 --- a/scripts/download-available-site-translations.mjs +++ b/scripts/download-available-site-translations.mjs @@ -16,11 +16,7 @@ const jsonFilePath = path.join( 'latest', 'available-site-translations.json' ); -try { - fs.mkdirSync( path.dirname( jsonFilePath ) ); -} catch ( err ) { - if ( err.code !== 'EEXIST' ) throw err; -} +fs.mkdirSync( path.dirname( jsonFilePath ), { recursive: true } ); const jsonFile = fs.createWriteStream( jsonFilePath ); await new Promise( ( resolve, reject ) => { diff --git a/scripts/make-pot.mjs b/scripts/make-pot.mjs deleted file mode 100755 index 079c784765..0000000000 --- a/scripts/make-pot.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath( import.meta.url ); -const __dirname = dirname( __filename ); -const projectRoot = dirname( __dirname ); - -const POT_DIR = join( projectRoot, 'apps', 'studio', 'out', 'pots' ); -const POT_FILE = join( POT_DIR, 'bundle-strings.pot' ); -const IMPORT_PAGE = 'https://translate.wordpress.com/projects/studio/import-originals/'; - -function executeCommand( command, description ) { - try { - console.log( `šŸ”„ ${ description }` ); - execSync( command, { stdio: 'inherit', cwd: projectRoot } ); - return true; - } catch ( error ) { - console.error( `āŒ Error: ${ description } failed` ); - console.error( error.message ); - return false; - } -} - -console.log( '✨ Starting pot files generation...\n' ); - -const commands = [ - { - command: 'rm -rf ./apps/studio/out/pots', - description: 'Removing existing pot files', - }, - { - command: - 'npx wp-babel-makepot "{apps/studio/src,apps/cli,tools/common}/**/*.{js,jsx,ts,tsx}" --ignore "apps/cli/node_modules/**/*,**/*.d.ts" --base "." --dir "./apps/studio/out/pots" --output "./apps/studio/out/pots/bundle-strings.pot"', - description: 'Generating pot file with wp-babel-makepot', - }, - { - command: `open -R "${ POT_FILE }"`, - description: `Revealing pot file in Finder: ${ POT_FILE }`, - }, - { - command: `open "${ IMPORT_PAGE }"`, - description: `Opening the translation import page ${ IMPORT_PAGE } in the browser`, - }, -]; - -for ( const { command, description } of commands ) { - if ( ! executeCommand( command, description ) ) { - process.exit( 1 ); - } -} - -console.log( '\nāœ… Success! Now drag and drop the "bundle-strings.pot" file into GlotPress.' );