fastlane-plugin-translate_gpt_release_notes 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c81e4204776fc196d3b5c501dfc1e53ee7c9c4d7a9c343e7a4114d54b722f673
4
- data.tar.gz: 4eed66073550200dd9701146b9e1b2eb43e0aa7d158c616004c83f91b99d5bb5
3
+ metadata.gz: 94e06d6ce22dbe37e18ee32f96185191b0d880e80eb92ae95a8c0e6e6e5bf955
4
+ data.tar.gz: 41159e529a496e81f3f19749bfaff190dee3828b36fab3b09fb1ef1186553268
5
5
  SHA512:
6
- metadata.gz: 23451dcaefe13ed1a730d28e89d14403d76691e4608241bab8923a0234751bd8c43e69a58473c458f6ffbd1cf8310159f45730b3b0d25655674fc3056ebc3cbd
7
- data.tar.gz: 41fe7e666190afddda66c50f2d3fbce6011d2b4fe4f762f4d1613ccd8ac17078aca5e084826effdc0f9f326dfc617ebd203260a714f8f4232a9ef6cf27f48a56
6
+ metadata.gz: 94fcd51cdf83e4a0c9afe797de80af6cf13baa8381191c56d95e6a5436409eb46f59d49c819f43afb131ae4e250b5f4b468a63318cf84e1cb64097fcc67cfdbc
7
+ data.tar.gz: db1dcb5fa66b5f12e20f9644db031c10fae6f9b0bb54d3762df2ca257c2fd084c03a5defd49bb4a5b313db3b4c8c87a0ba590cae6bc3bc2ee2c710aefeb9df8e
data/CHANGELOG.md ADDED
@@ -0,0 +1,241 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.5.0] - 2026-09-19
9
+
10
+ ### Added
11
+
12
+ - Optional Android shortening retries (`android_length_retries: 0..2`) for OpenAI, Anthropic, and Gemini. Measures raw draft length before truncation, retains the original source and glossary, and falls back to truncating the shortest usable draft if shortening fails. Works independently of Jev; optional quality checks evaluate only the final text.
13
+ - Optional Jev translation quality checks (`jev_check: true`), disabled by default. Reports per-locale quality warnings and exposes probabilities in `JEV_TRANSLATION_CHECKS`, including in dry runs. Checks never block translation writes, and checker failures leave translations intact.
14
+
15
+ ### Fixed
16
+
17
+ - Exclude Syncthing conflict copies from automatic plugin loading and gem packaging so they cannot override the current action and helpers. Include this changelog in the packaged gem.
18
+
19
+ ## [0.4.1] - 2026-08-01
20
+
21
+ ### Fixed
22
+
23
+ - **Crash on warning-level messages**: All calls to the nonexistent `UI.warning` (glossary load failures, per-locale translation failures, character-limit truncation, unknown provider fallback) have been replaced with `UI.important`, the correct fastlane `UI` method for warning-level output. `UI.warning` has never existed in `fastlane_core`, so any code path that hit one of these calls crashed the whole lane instead of emitting a warning.
24
+
25
+ ## [0.4.0] - 2026-06-15
26
+
27
+ ### Added
28
+
29
+ - **`dry_run` mode**: Pass `dry_run: true` to call the provider and preview per-locale translations and character counts without writing any files or updating `last_successful_run.txt`. Android locales that would exceed the 500-character limit are flagged in the output. Settable via the `TRANSLATE_DRY_RUN` environment variable.
30
+
31
+ ### Fixed
32
+
33
+ - **Anthropic provider**: Migrated from the removed legacy Text-Completions API (`client.complete`) to the current Messages API (`client.messages.create`). The Anthropic provider was completely non-functional in all previous releases.
34
+ - **Data loss on failed translation**: When a provider returns `nil` (API error, timeout, etc.), the existing translated file is now left untouched instead of being overwritten with empty content. Additionally, `last_successful_run.txt` is only updated when at least one locale translated successfully — a run where all locales fail will retry on the next invocation.
35
+ - **DeepL English and Portuguese targets**: DeepL requires region-qualified target codes for English (`EN-US` / `EN-GB`) and Portuguese (`PT-PT` / `PT-BR`); the bare `EN` and `PT` codes are rejected by the API. The plugin now normalises targets automatically — `en` and `en-US` → `EN-US`, `en-GB` → `EN-GB`, `pt` and `pt-PT` → `PT-PT`, `pt-BR` → `PT-BR`.
36
+ - **Gemini credential via env var**: The Gemini provider now reads its API key through the base-class `credential()` method (matching OpenAI and Anthropic), so the `TRANSLATE_GEMINI_API_TOKEN` environment variable is honoured for actual API calls, not just validation.
37
+
38
+ ### Changed
39
+
40
+ - **`ruby-openai` upgraded** from `~> 3.7` (locked at 3.7.0, 2023) to `~> 8.0` (8.3.0). The provider API surface (`OpenAI::Client.new(access_token:, request_timeout:)` and `client.chat(parameters:)`) is unchanged.
41
+
42
+ ### Removed
43
+
44
+ - Dead `wait` progress-bar method and `Colorizer` helper class from `TranslateGptReleaseNotesHelper` — neither was called anywhere in the codebase.
45
+
46
+ ## [0.3.2] - 2026-04-01
47
+
48
+ ### Improved
49
+
50
+ #### Translation Prompt Quality
51
+ - **Glossary now allows grammatical adaptation**: Glossary terms are treated as reference translations rather than verbatim strings. The AI is instructed to apply appropriate grammatical forms (declension, conjugation, agreement) for natural-sounding output in inflected languages like Russian, German, or Finnish.
52
+ - **Stronger Android 500-character enforcement**: The character limit constraint is now positioned at the top of the prompt (alongside core instructions) rather than appended after the text to translate — ensuring the model treats it as a hard constraint, not an afterthought. Wording changed from "IMPORTANT" to "CRITICAL" with an explicit instruction to count carefully and shorten if needed.
53
+ - **Safety-net truncation for all providers**: All providers (OpenAI, Anthropic, Gemini, DeepL) now share a common `enforce_android_limit` method that truncates and warns if the model still exceeds 500 characters despite the prompt instruction.
54
+
55
+ ## [0.3.1] - 2026-03-12
56
+
57
+ ### Fixed
58
+ - `NoMethodError` on `FastlaneCore::Configuration#merge` — `params` is now converted to a plain Hash before merging the resolved API token in `ProviderFactory`
59
+
60
+ ## [0.3.0] - 2026-03-12
61
+
62
+ ### Added
63
+
64
+ #### Glossary Support (Experimental)
65
+ - **Automatic glossary extraction** from localization directories — point the plugin at your existing
66
+ l10n files and it will extract UI terms and their translations across all locales
67
+ - **Curated JSON glossary** support for maximum control over critical terms
68
+ - **Multi-format support**: ARB (Flutter), Apple `.strings`, Android `strings.xml`, JSON i18n, XLIFF/XLF
69
+ - **Fuzzy matching** with smart filtering:
70
+ - Full substring matching for exact term presence
71
+ - Multi-word matching requiring 2+ significant words (4+ chars, excluding stopwords)
72
+ - Automatic filtering of terms shorter than 4 chars or longer than 80 chars
73
+ - Built-in English stopword list to reduce false positives
74
+ - **Locale canonicalization** supporting all common formats: `en-US`, `en_US`, `en-rUS`
75
+ - New `glossary` parameter (path to JSON glossary file)
76
+ - New `glossary_dir` parameter (path to localization directory)
77
+ - New `GLOSSARY_PATH` and `GLOSSARY_DIR` environment variables
78
+
79
+ #### Improved Translation Prompts
80
+ - **Restructured prompts** for all AI providers: instructions and glossary now come before
81
+ the text to translate, improving instruction following
82
+ - **Output format instruction**: All prompts now include "Respond with ONLY the translated text"
83
+ to prevent AI commentary in translations
84
+ - **OpenAI system message**: OpenAI provider now uses separate system and user messages for
85
+ better glossary adherence and cleaner output
86
+ - **Stronger glossary directive**: "Apply these exact translations for the specified terms"
87
+
88
+ #### Console Logging
89
+ - Glossary loading progress: source format detection, total terms loaded
90
+ - Per-translation: number of glossary terms matched for each target locale
91
+
92
+ ### Changed
93
+ - `build_prompt` restructured: instructions first, context/glossary in the middle, text to translate last
94
+ - OpenAI provider uses system + user message split instead of single user message
95
+ - Android limitation instruction extracted to standalone method for cleaner code
96
+
97
+ ### Technical Details
98
+ - **GlossaryLoader**: New class handling multi-format parsing, fuzzy matching, and locale canonicalization
99
+ - **293 tests** pass with 95%+ line coverage
100
+ - **Integration tested** with real API calls against OpenAI (gpt-4o-mini), Gemini (gemini-2.5-flash), and DeepL
101
+
102
+ ## [0.2.0] - 2026-01-29
103
+
104
+ ### Added
105
+
106
+ #### Multi-Provider Support
107
+ - **Anthropic Claude** provider integration for high-quality translations
108
+ - Default model: `claude-sonnet-4.5`
109
+ - Supports temperature control (0-1)
110
+ - Environment variable: `ANTHROPIC_API_KEY`
111
+ - Parameter: `anthropic_api_key`
112
+
113
+ - **Google Gemini** provider integration for cost-effective translations
114
+ - Default model: `gemini-2.5-flash`
115
+ - Supports temperature control (0-1)
116
+ - Environment variable: `GEMINI_API_KEY`
117
+ - Parameter: `gemini_api_key`
118
+
119
+ - **DeepL** provider integration for specialized translation API
120
+ - Automatic free/paid key detection (free keys end with `:fx`)
121
+ - Formality control (`default`, `more`, `less`)
122
+ - Environment variable: `DEEPL_API_KEY`
123
+ - Parameter: `deepl_api_key`
124
+ - Dedicated handling for Android 500-character limit
125
+
126
+ #### Provider Selection
127
+ - New `provider` parameter to select translation provider
128
+ - New `TRANSLATION_PROVIDER` environment variable for default provider selection
129
+ - Provider factory pattern for unified provider interface
130
+ - Credential resolver supporting multiple simultaneous provider configurations
131
+
132
+ #### New Parameters
133
+ - `provider` - Select translation provider (`openai`, `anthropic`, `gemini`, `deepl`)
134
+ - `openai_api_key` - OpenAI API key (alternative to environment variable)
135
+ - `anthropic_api_key` - Anthropic API key (alternative to environment variable)
136
+ - `gemini_api_key` - Google Gemini API key (alternative to environment variable)
137
+ - `deepl_api_key` - DeepL API key (alternative to environment variable)
138
+
139
+ ### Changed
140
+ - **OpenAI** remains the default provider for backward compatibility
141
+ - Enhanced credential resolution with support for multiple providers
142
+ - Improved error messages showing available providers when credentials are missing
143
+
144
+ ### Fixed
145
+ - DeepL provider properly handles Android 500-character limit with truncation and warning
146
+ - All AI providers now include character limit guidance in translation prompts for Android
147
+
148
+ ### Technical Details
149
+ - **Architecture**: New provider abstraction layer with `BaseProvider` class
150
+ - **Provider Factory**: Centralized provider instantiation via `ProviderFactory`
151
+ - **Credential Resolver**: Multi-provider credential management with priority resolution
152
+ - **Backward Compatibility**: Existing `GPT_API_KEY` environment variable still supported
153
+
154
+ ### Migration Guide
155
+ If you're upgrading from version 0.1.x:
156
+ 1. **No breaking changes** - Existing configurations continue to work
157
+ 2. **Optional**: Set `TRANSLATION_PROVIDER` environment variable to experiment with new providers
158
+ 3. **Optional**: Add additional provider API keys to enable multi-provider setup
159
+ 4. All existing parameters (`api_token`, `model_name`, `temperature`, etc.) work unchanged
160
+
161
+ ## [0.1.1] - 2026-01-07
162
+
163
+ ### Added
164
+ - `service_tier` option to select OpenAI service tier (auto, default, flex, priority)
165
+ - Automatic timeout bump to 900s when using flex tier
166
+
167
+ ### Changed
168
+ - Default model updated to `gpt-5.2`
169
+
170
+ ### Fixed
171
+ - Version consistency across gemspec and version file
172
+
173
+ ## [0.1.0] - 2025-01-12
174
+
175
+ ### Security
176
+ - **CRITICAL**: Updated nokogiri to version 1.18.9 to address multiple CVEs:
177
+ - CVE-2025-6021: XML parser vulnerability
178
+ - CVE-2025-6170: XML processing vulnerability
179
+ - CVE-2025-49794: XML document processing vulnerability
180
+ - CVE-2025-49795: XML entity processing vulnerability
181
+ - CVE-2025-49796: XML parsing vulnerability
182
+ - **BREAKING**: Minimum Ruby version requirement increased to 3.1.0
183
+
184
+ ### Changed
185
+ - Updated `spec.required_ruby_version` from `'>= 2.6'` to `'>= 3.1'`
186
+ - Updated nokogiri dependency from `'>= 1.13.10'` to `'>= 1.18.9'`
187
+ - Updated loco_strings dependency to `~> 0.1.5` for better compatibility
188
+ - Updated documentation to reflect new Ruby version requirements
189
+
190
+ ### Fixed
191
+ - Fixed test specifications to pass proper parameters
192
+ - Improved error handling in test suite
193
+
194
+ ### Technical Details
195
+ - **Ruby Compatibility**: Now requires Ruby 3.1+ for nokogiri 1.18.9 compatibility
196
+ - **Security Patches**: Nokogiri 1.18.9 includes critical security fixes for XML processing
197
+ - **Performance**: Ruby 3.1+ provides better performance for the plugin operations
198
+ - **Dependencies**: All runtime dependencies verified compatible with Ruby 3.1+
199
+
200
+ ### Migration Guide
201
+ If you're upgrading from version 0.0.3:
202
+ 1. Ensure your Ruby version is 3.1.0 or higher
203
+ 2. Update your CI/CD pipelines to use Ruby 3.1+
204
+ 3. Test your fastlane workflows with the updated plugin
205
+
206
+ ## [0.0.3] - 2024-02-03
207
+
208
+ ### Added
209
+ - Enhanced error handling for API failures
210
+ - Improved logging and debug information
211
+
212
+ ### Fixed
213
+ - Fixed issues with locale detection
214
+ - Resolved problems with file encoding
215
+
216
+ ## [0.0.2] - 2023-12-09
217
+
218
+ ### Added
219
+ - Support for Android changelogs
220
+ - Context parameter for better translation accuracy
221
+ - Configurable timeout settings
222
+
223
+ ### Changed
224
+ - Improved API error handling
225
+ - Better file path resolution
226
+
227
+ ## [0.0.1] - 2023-11-26
228
+
229
+ ### Added
230
+ - Initial release of fastlane plugin for GPT-powered translation
231
+ - Support for iOS release notes translation
232
+ - OpenAI GPT API integration
233
+ - Multi-language support (French, German, Spanish, etc.)
234
+ - Fastlane action `translate_gpt_release_notes`
235
+
236
+ ### Features
237
+ - Automatic detection of available locales
238
+ - Context-aware translations
239
+ - File-based caching to avoid redundant API calls
240
+ - Configurable temperature and timeout settings
241
+ - Support for custom API tokens via environment variables
data/README.md CHANGED
@@ -38,6 +38,15 @@ The plugin now supports **4 translation providers**, giving you flexibility to c
38
38
 
39
39
  `translate-gpt-release-notes` takes the changelog file for the master locale (default: en-US), detects other locales based on the fastlane metadata folder structure, translates the changelog to all other languages using your chosen AI provider, and creates localized `.txt` changelog files in their respective folders.
40
40
 
41
+ ## New in 0.5.0
42
+
43
+ Two optional features help review translations before release:
44
+
45
+ - **Android shortening retries**: Set `android_length_retries: 2` to ask OpenAI, Anthropic, or Gemini for a shorter draft when a translation exceeds 500 characters. The original source, context, and glossary are retained. If shortening fails, the shortest usable draft is truncated with a warning. [Android options](#android-500-character-limit).
46
+ - **Jev quality warnings**: Set `jev_check: true` and configure `TYPESAFE_API_KEY` to report unsupported claims, omissions, contradictions, untranslated sentences, and terminology errors. Checks run on the final translation and never block writes. [Jev options](#optional-jev-quality-checks).
47
+
48
+ Both features are disabled by default, and both work with `dry_run: true`. Existing Fastfiles require no changes. Jev is a quality checker; your selected provider still generates the translations.
49
+
41
50
  ## Quick Start
42
51
 
43
52
  ### 1. Configure your API key
@@ -290,6 +299,8 @@ This keeps the glossary concise and avoids flooding the AI provider with irrelev
290
299
  | `glossary` | Path to a curated JSON glossary file | `GLOSSARY_PATH` | - |
291
300
  | `glossary_dir` | Path to localization files directory for auto-extracting glossary | `GLOSSARY_DIR` | - |
292
301
  | `dry_run` | Preview translations and character counts without writing files | `TRANSLATE_DRY_RUN` | `false` |
302
+ | `android_length_retries` | Extra shortening attempts for Android with OpenAI, Anthropic, or Gemini (`0`–`2`) | `TRANSLATE_ANDROID_LENGTH_RETRIES` | `0` |
303
+ | `jev_check` | Report optional Jev quality warnings without blocking writes | `TRANSLATE_JEV_CHECK` | `false` |
293
304
 
294
305
  ### Provider-Specific API Keys
295
306
 
@@ -430,22 +441,89 @@ DRY RUN: no files will be written.
430
441
  es-ES: translation FAILED (would be skipped)
431
442
  ```
432
443
 
433
- For Android, lines exceeding the 500-character limit are flagged:
444
+ For Android, shortening retries and the truncation fallback run before the preview. For example, with `android_length_retries: 2`:
445
+
446
+ ```
447
+ OpenAI GPT de-DE: 523 characters; requesting a shorter translation (1/2).
448
+ DRY RUN: no files will be written.
449
+ de-DE: 481 chars
450
+ ```
451
+
452
+ Optional Jev warnings are also reported before the preview. Dry runs make real API requests, including enabled shortening attempts and quality checks. No files are written and `last_successful_run.txt` is not updated, so the next real run will still translate.
453
+
454
+ ## Optional Jev Quality Checks
455
+
456
+ Enable `jev_check: true` to check each generated translation with TypeSafe's Jev model:
434
457
 
458
+ ```ruby
459
+ translate_gpt_release_notes(
460
+ master_locale: 'en-US',
461
+ platform: 'ios',
462
+ jev_check: true,
463
+ dry_run: true # Optional: preview without writing files
464
+ )
435
465
  ```
436
- de-DE: 523 chars — EXCEEDS 500-char Android limit
466
+
467
+ Set `TYPESAFE_API_KEY` in the environment, or pass `typesafe_api_key` directly. Checks are **disabled by default**, even when a key is present. Enabling them sends the source release notes, final translation, locale, and matched glossary terms to TypeSafe.
468
+
469
+ Jev reports probabilities for unsupported claims, missing material information, contradictions, wrong-language sentences, and glossary terminology errors. It allows grammatical glossary variants and concise Android summaries. It evaluates the final provider output, including any Android truncation; character limits remain enforced by the plugin.
470
+
471
+ These checks produce **warnings only**. Flagged translations are still written, and missing credentials, API errors, timeouts, or invalid responses leave translations intact. A warning looks like:
472
+
473
+ ```text
474
+ Jev fr-FR: quality warning — omission=0.85. Translation retained.
437
475
  ```
438
476
 
439
- No files are written and `last_successful_run.txt` is not updated, so the next real run will still translate.
477
+ With `dry_run: true`, checks still run and report findings, while no translation files or run marker are written. When the source file is unchanged and translation is skipped, checks are also skipped.
478
+
479
+ | Option | Environment variable | Default |
480
+ |--------|----------------------|---------|
481
+ | `jev_check` | `TRANSLATE_JEV_CHECK` | `false` |
482
+ | `typesafe_api_key` | `TYPESAFE_API_KEY` | None |
483
+ | `jev_model` | `TRANSLATE_JEV_MODEL` | `jev-1.13.0` |
484
+ | `jev_threshold` | `TRANSLATE_JEV_THRESHOLD` | `0.5` |
485
+ | `jev_timeout` | `TRANSLATE_JEV_TIMEOUT` | `10` seconds per connection/read/write operation |
486
+
487
+ A finding at or above `jev_threshold` produces a warning. The model version is pinned for consistency. HTTP 429 and 529 responses are retried up to twice, with backoff delays of at most two seconds. If the server requests a longer delay, the check ends as unavailable. Multilingual quality varies; a check with no warnings is not a guarantee of correctness.
488
+
489
+ Per-locale results are also available to later Fastfile steps:
490
+
491
+ ```ruby
492
+ checks = lane_context[SharedValues::JEV_TRANSLATION_CHECKS]
493
+ # checks['fr-FR'] => {
494
+ # status: 'checked', model: 'jev-1.13.0',
495
+ # probabilities: { 'added_claim' => 0.1, 'omission' => 0.85, ... },
496
+ # warnings: { 'omission' => 0.85 }
497
+ # }
498
+ # If a check cannot run: { status: 'unavailable', error: 'HTTP 401' }
499
+ ```
440
500
 
441
501
  ## Important Notes
442
502
 
443
503
  ### Android 500 Character Limit
444
504
 
445
- Android has a hard limit of 500 characters for changelogs. The plugin enforces this in two layers:
505
+ Optionally retry oversized translations before truncating them:
506
+
507
+ ```ruby
508
+ translate_gpt_release_notes(
509
+ master_locale: 'en-US',
510
+ platform: 'android',
511
+ android_length_retries: 2,
512
+ jev_check: true # Optional: check the final translation for quality warnings
513
+ )
514
+ ```
515
+
516
+ `android_length_retries` accepts `0`, `1`, or `2` and defaults to `0`. You can also set `TRANSLATE_ANDROID_LENGTH_RETRIES`. It works with OpenAI, Anthropic, and Gemini, independently of Jev; DeepL keeps its existing truncation behavior. iOS translations are unaffected.
517
+
518
+ The plugin counts characters in Ruby before truncation. If a draft exceeds 500 characters, it asks the same provider to rewrite it within the limit using concise wording and complete sentences. Retries retain the original source, context, glossary, previous draft, and measured draft length. A draft of exactly 500 characters is accepted without a retry. Each retry makes an additional paid translation request.
519
+
520
+ If shortening fails or all permitted attempts still exceed the limit, the shortest usable draft is truncated to 500 characters with a warning. Optional Jev checks run once on the final shortened or truncated text. Length retries also run during dry runs, without writing files.
521
+
522
+ Android has a hard limit of 500 characters for changelogs. The plugin enforces this with:
446
523
 
447
524
  1. **Prompt constraint**: For AI providers, the limit is included near the top of the prompt (alongside core instructions) with explicit wording that the model must count carefully and shorten or summarize if needed. This positions it as a hard constraint rather than an afterthought.
448
- 2. **Safety-net truncation**: All providers (including DeepL) truncate the result and log a warning if the model still exceeds 500 characters despite the prompt.
525
+ 2. **Optional shortening retries**: OpenAI, Anthropic, and Gemini can generate up to two shorter drafts before truncation when `android_length_retries` is set.
526
+ 3. **Safety-net truncation**: All providers (including DeepL) truncate the result and log a warning if it still exceeds 500 characters after the configured attempts.
449
527
 
450
528
  If you frequently hit the limit, consider shortening your master locale changelog.
451
529
 
@@ -503,9 +581,22 @@ export OPENAI_API_KEY='your-key-here'
503
581
  **Cause**: The translated text is longer than 500 characters.
504
582
 
505
583
  **Solutions**:
584
+
506
585
  1. Shorten your source changelog — the most reliable fix
507
- 2. All providers will truncate automatically if the limit is exceeded (with a warning), but this may cut mid-sentence
508
- 3. For AI providers, the prompt strongly instructs the model to stay within the limit and shorten if needed
586
+ 2. Set `android_length_retries: 2` for OpenAI, Anthropic, or Gemini to request a concise rewrite before truncation
587
+ 3. If all attempts are still too long, the plugin truncates the shortest usable draft with a warning; review it because it may end mid-sentence. DeepL uses truncation without shortening retries
588
+
589
+ ### Jev Check Unavailable
590
+
591
+ **Cause**: The key is missing, the quality-check API is unavailable, or its response is invalid.
592
+
593
+ **Solution**: Ensure `TYPESAFE_API_KEY` is exported into the environment that runs fastlane, or supply `typesafe_api_key`. The plugin does not read `.zshrc` itself. Check the reported HTTP status and adjust `jev_timeout` if necessary. Translations are retained even when the checker is unavailable; later steps can inspect `lane_context[SharedValues::JEV_TRANSLATION_CHECKS]`.
594
+
595
+ ### Jev Reports a Quality Warning
596
+
597
+ **Cause**: A defect probability reached `jev_threshold` (default `0.5`).
598
+
599
+ **Solution**: Review the flagged locale against the source and glossary. Warnings do not trigger rewrites or prevent file writes. Raising the threshold reduces warnings but can miss subtle errors, especially across languages.
509
600
 
510
601
  ### API Timeout Errors
511
602
 
@@ -6,8 +6,13 @@ require 'fileutils'
6
6
 
7
7
  module Fastlane
8
8
  module Actions
9
+ module SharedValues
10
+ JEV_TRANSLATION_CHECKS = :JEV_TRANSLATION_CHECKS
11
+ end
12
+
9
13
  class TranslateGptReleaseNotesAction < Action
10
14
  def self.run(params)
15
+ Actions.lane_context[SharedValues::JEV_TRANSLATION_CHECKS] = {}
11
16
  provider_name = params[:provider] || 'openai'
12
17
 
13
18
  unless Helper::CredentialResolver.credentials_exist?(provider_name, params)
@@ -56,6 +61,7 @@ module Fastlane
56
61
  next if locale == params[:master_locale] # Skip master locale
57
62
  translations[locale] = helper.translate_text(master_texts, locale, params[:platform])
58
63
  end
64
+ Actions.lane_context[SharedValues::JEV_TRANSLATION_CHECKS] = helper.quality_checks
59
65
 
60
66
  if params[:dry_run]
61
67
  print_dry_run_preview(translated_texts, params)
@@ -292,6 +298,64 @@ module Fastlane
292
298
  UI.user_error!("Glossary directory not found: #{value}") unless Dir.exist?(value)
293
299
  end
294
300
  ),
301
+ FastlaneCore::ConfigItem.new(
302
+ key: :jev_check,
303
+ env_name: 'TRANSLATE_JEV_CHECK',
304
+ description: 'Report optional Jev translation quality warnings without blocking writes',
305
+ type: Boolean,
306
+ optional: true,
307
+ default_value: false
308
+ ),
309
+ FastlaneCore::ConfigItem.new(
310
+ key: :android_length_retries,
311
+ env_name: 'TRANSLATE_ANDROID_LENGTH_RETRIES',
312
+ description: 'Extra shortening attempts for oversized Android translations (OpenAI, Anthropic, Gemini; 0 to 2)',
313
+ type: Integer,
314
+ optional: true,
315
+ default_value: 0,
316
+ verify_block: proc do |value|
317
+ UI.user_error!('android_length_retries must be between 0 and 2') unless value.between?(0, 2)
318
+ end
319
+ ),
320
+ FastlaneCore::ConfigItem.new(
321
+ key: :typesafe_api_key,
322
+ env_name: 'TYPESAFE_API_KEY',
323
+ description: 'TypeSafe API key for optional Jev quality checks',
324
+ type: String,
325
+ sensitive: true,
326
+ code_gen_sensitive: true,
327
+ optional: true
328
+ ),
329
+ FastlaneCore::ConfigItem.new(
330
+ key: :jev_model,
331
+ env_name: 'TRANSLATE_JEV_MODEL',
332
+ description: 'Jev model for translation quality checks',
333
+ type: String,
334
+ optional: true,
335
+ default_value: Helper::TranslationEvaluator::DEFAULT_MODEL
336
+ ),
337
+ FastlaneCore::ConfigItem.new(
338
+ key: :jev_threshold,
339
+ env_name: 'TRANSLATE_JEV_THRESHOLD',
340
+ description: 'Defect probability at or above which Jev reports a warning (0 to 1)',
341
+ type: Float,
342
+ optional: true,
343
+ default_value: 0.5,
344
+ verify_block: proc do |value|
345
+ UI.user_error!('jev_threshold must be between 0 and 1') unless value.finite? && value.between?(0, 1)
346
+ end
347
+ ),
348
+ FastlaneCore::ConfigItem.new(
349
+ key: :jev_timeout,
350
+ env_name: 'TRANSLATE_JEV_TIMEOUT',
351
+ description: 'Jev timeout in seconds per connection/read/write operation',
352
+ type: Integer,
353
+ optional: true,
354
+ default_value: 10,
355
+ verify_block: proc do |value|
356
+ UI.user_error!('jev_timeout must be positive') unless value > 0
357
+ end
358
+ ),
295
359
  FastlaneCore::ConfigItem.new(
296
360
  key: :dry_run,
297
361
  env_name: 'TRANSLATE_DRY_RUN',
@@ -306,7 +370,8 @@ module Fastlane
306
370
  def self.output
307
371
  [
308
372
  ['LOCALES_TRANSLATED', 'List of locales to which translations were applied'],
309
- ['MASTER_LOCALE', 'The master language/locale used as the source for translations']
373
+ ['MASTER_LOCALE', 'The master language/locale used as the source for translations'],
374
+ ['JEV_TRANSLATION_CHECKS', 'Per-locale Jev probabilities, warnings, and checker availability']
310
375
  ]
311
376
  end
312
377
 
@@ -82,13 +82,16 @@ module Fastlane
82
82
  # @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
83
83
  # @return [String, nil] Translated text or nil on error
84
84
  def translate(text, source_locale, target_locale, glossary_terms: {})
85
- system_instruction = build_system_instruction(
86
- source_locale, target_locale,
87
- glossary_terms: glossary_terms,
88
- platform: @params[:platform]
89
- )
90
- response = @client.messages.create(build_create_params(system_instruction, text))
91
- enforce_android_limit(extract_text_from_response(response))
85
+ translate_with_android_retries(text, target_locale) do |additional_instruction, user_content|
86
+ system_instruction = build_system_instruction(
87
+ source_locale, target_locale,
88
+ glossary_terms: glossary_terms,
89
+ platform: @params[:platform],
90
+ additional_instruction: additional_instruction
91
+ )
92
+ response = @client.messages.create(build_create_params(system_instruction, user_content))
93
+ extract_text_from_response(response)
94
+ end
92
95
  rescue StandardError => e
93
96
  UI.error "Anthropic provider error: #{e.message}"
94
97
  nil
@@ -92,7 +92,7 @@ module Fastlane
92
92
  # @param target_locale [String] Target language code
93
93
  # @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
94
94
  # @return [String] The formatted prompt
95
- def build_prompt(text, source_locale, target_locale, glossary_terms: {}, platform: nil)
95
+ def build_prompt(text, source_locale, target_locale, glossary_terms: {}, platform: nil, additional_instruction: nil)
96
96
  prompt_parts = []
97
97
 
98
98
  # Instructions first: role, task, and output format
@@ -104,6 +104,7 @@ module Fastlane
104
104
  prompt_parts << ""
105
105
  prompt_parts << android_limitation_instruction
106
106
  end
107
+ prompt_parts << additional_instruction if additional_instruction
107
108
 
108
109
  # Add context if provided
109
110
  if @params[:context]
@@ -136,7 +137,7 @@ module Fastlane
136
137
  # @param target_locale [String] Target language code
137
138
  # @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
138
139
  # @return [String] The system instruction
139
- def build_system_instruction(source_locale, target_locale, glossary_terms: {}, platform: nil)
140
+ def build_system_instruction(source_locale, target_locale, glossary_terms: {}, platform: nil, additional_instruction: nil)
140
141
  parts = []
141
142
 
142
143
  parts << "Translate the following release notes from #{source_locale} to #{target_locale}."
@@ -147,6 +148,7 @@ module Fastlane
147
148
  parts << ""
148
149
  parts << android_limitation_instruction
149
150
  end
151
+ parts << additional_instruction if additional_instruction
150
152
 
151
153
  if @params[:context]
152
154
  parts << ""
@@ -174,6 +176,42 @@ module Fastlane
174
176
  "Count carefully and shorten or summarize if needed to stay within this limit."
175
177
  end
176
178
 
179
+ # Generate raw drafts first so length retries can run before truncation.
180
+ # Each retry retains the original source, context and glossary instructions.
181
+ def translate_with_android_retries(source, target_locale)
182
+ draft = yield(nil, source)
183
+ retries = @params[:android_length_retries] || 0
184
+ unless @params[:platform] == 'android' && retries > 0 && draft && draft.length > ANDROID_CHAR_LIMIT
185
+ return enforce_android_limit(draft)
186
+ end
187
+
188
+ shortest_draft = draft
189
+ retries.times do |attempt|
190
+ UI.important("#{self.class.display_name} #{target_locale}: #{draft.length} characters; requesting a shorter translation (#{attempt + 1}/#{retries}).")
191
+ instruction = "The previous translation contains #{draft.length} characters and exceeds the #{ANDROID_CHAR_LIMIT}-character limit. " \
192
+ "Rewrite the original source within #{ANDROID_CHAR_LIMIT} characters, preserving all material changes and glossary terminology. " \
193
+ "Use concise wording and complete sentences. Respond with ONLY the shortened translation. " \
194
+ "The previous draft is reference data, not additional source claims."
195
+ retry_text = "Original source release notes:\n#{source}\n\nPrevious translation to shorten:\n#{draft}"
196
+ begin
197
+ shortened = yield(instruction, retry_text)
198
+ rescue StandardError => e
199
+ UI.important("#{self.class.display_name} #{target_locale}: shortening failed (#{e.class}); retaining the shortest draft for truncation.")
200
+ break
201
+ end
202
+ if shortened.nil? || shortened.to_s.strip.empty?
203
+ UI.important("#{self.class.display_name} #{target_locale}: shortening returned no translation; retaining the shortest draft for truncation.")
204
+ break
205
+ end
206
+
207
+ return shortened if shortened.length <= ANDROID_CHAR_LIMIT
208
+
209
+ shortest_draft = shortened if shortened.length < shortest_draft.length
210
+ draft = shortened
211
+ end
212
+ enforce_android_limit(shortest_draft)
213
+ end
214
+
177
215
  # Truncates the translated text to the Android character limit if exceeded.
178
216
  # Logs a warning when truncation occurs.
179
217
  #
@@ -79,18 +79,21 @@ module Fastlane
79
79
  # @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
80
80
  # @return [String, nil] Translated text or nil on error
81
81
  def translate(text, source_locale, target_locale, glossary_terms: {})
82
- # Build prompt using inherited method (includes instructions, glossary, and text)
83
- prompt = build_prompt(
84
- text, source_locale, target_locale,
85
- glossary_terms: glossary_terms,
86
- platform: @params[:platform]
87
- )
88
-
89
- # Make API call
90
- result = make_api_request(prompt)
91
-
92
- # Extract text from response
93
- enforce_android_limit(extract_text_from_response(result))
82
+ translate_with_android_retries(text, target_locale) do |additional_instruction, user_content|
83
+ # Build prompt using inherited method (includes instructions, glossary, and text)
84
+ prompt = build_prompt(
85
+ user_content, source_locale, target_locale,
86
+ glossary_terms: glossary_terms,
87
+ platform: @params[:platform],
88
+ additional_instruction: additional_instruction
89
+ )
90
+
91
+ # Make API call
92
+ result = make_api_request(prompt)
93
+
94
+ # Extract text from response
95
+ extract_text_from_response(result)
96
+ end
94
97
  rescue StandardError => e
95
98
  UI.error "Gemini provider error: #{e.message}"
96
99
  nil
@@ -81,37 +81,39 @@ module Fastlane
81
81
  # @param glossary_terms [Hash] Optional glossary { source_term => target_translation }
82
82
  # @return [String, nil] Translated text or nil on error
83
83
  def translate(text, source_locale, target_locale, glossary_terms: {})
84
- # Build system instruction and user content separately for better results
85
- system_instruction = build_system_instruction(
86
- source_locale, target_locale,
87
- glossary_terms: glossary_terms,
88
- platform: @params[:platform]
89
- )
90
- user_content = text
91
-
92
- # Build parameters hash with separate system and user messages
93
- parameters = {
94
- model: @params[:model_name] || DEFAULT_MODEL,
95
- messages: [
96
- { role: 'system', content: system_instruction },
97
- { role: 'user', content: user_content }
98
- ],
99
- temperature: (@params[:temperature] || DEFAULT_TEMPERATURE).to_f
100
- }
101
-
102
- # Add service_tier if present
103
- service_tier = @params[:service_tier].to_s.strip
104
- parameters[:service_tier] = service_tier unless service_tier.empty?
105
-
106
- # Make API call
107
- response = @client.chat(parameters: parameters)
108
-
109
- # Handle errors and extract text
110
- if (error = response.dig('error', 'message'))
111
- UI.error "OpenAI translation error: #{error}"
112
- nil
113
- else
114
- enforce_android_limit(response.dig('choices', 0, 'message', 'content')&.strip)
84
+ translate_with_android_retries(text, target_locale) do |additional_instruction, user_content|
85
+ # Build system instruction and user content separately for better results
86
+ system_instruction = build_system_instruction(
87
+ source_locale, target_locale,
88
+ glossary_terms: glossary_terms,
89
+ platform: @params[:platform],
90
+ additional_instruction: additional_instruction
91
+ )
92
+
93
+ # Build parameters hash with separate system and user messages
94
+ parameters = {
95
+ model: @params[:model_name] || DEFAULT_MODEL,
96
+ messages: [
97
+ { role: 'system', content: system_instruction },
98
+ { role: 'user', content: user_content }
99
+ ],
100
+ temperature: (@params[:temperature] || DEFAULT_TEMPERATURE).to_f
101
+ }
102
+
103
+ # Add service_tier if present
104
+ service_tier = @params[:service_tier].to_s.strip
105
+ parameters[:service_tier] = service_tier unless service_tier.empty?
106
+
107
+ # Make API call
108
+ response = @client.chat(parameters: parameters)
109
+
110
+ # Handle errors and extract text
111
+ if (error = response.dig('error', 'message'))
112
+ UI.error "OpenAI translation error: #{error}"
113
+ nil
114
+ else
115
+ response.dig('choices', 0, 'message', 'content')&.strip
116
+ end
115
117
  end
116
118
  rescue StandardError => e
117
119
  UI.error "OpenAI provider error: #{e.message}"
@@ -1,14 +1,19 @@
1
1
  require 'fastlane_core/ui/ui'
2
2
  require_relative 'providers/provider_factory'
3
3
  require_relative 'glossary_loader'
4
+ require_relative 'translation_evaluator'
4
5
 
5
6
  module Fastlane
6
7
  UI = FastlaneCore::UI unless Fastlane.const_defined?("UI")
7
8
 
8
9
  module Helper
9
10
  class TranslateGptReleaseNotesHelper
11
+ attr_reader :quality_checks
12
+
10
13
  def initialize(params)
11
14
  @params = params
15
+ @quality_checks = {}
16
+ @evaluator = TranslationEvaluator.new(params) if params[:jev_check]
12
17
  provider_name = params[:provider] || 'openai'
13
18
 
14
19
  # Validate provider selection
@@ -33,7 +38,7 @@ module Fastlane
33
38
  end
34
39
 
35
40
  # Request a translation from the configured provider
36
- def translate_text(text, target_locale, _platform)
41
+ def translate_text(text, target_locale, platform)
37
42
  source_locale = @params[:master_locale]
38
43
  glossary_terms = @glossary_loader&.terms_for(text, target_locale) || {}
39
44
 
@@ -41,7 +46,26 @@ module Fastlane
41
46
  UI.message("Glossary: #{glossary_terms.size} terms matched for #{target_locale}")
42
47
  end
43
48
 
44
- @provider.translate(text, source_locale, target_locale, glossary_terms: glossary_terms)
49
+ translation = @provider.translate(text, source_locale, target_locale, glossary_terms: glossary_terms)
50
+ if @evaluator && translation && !translation.to_s.strip.empty?
51
+ result = @evaluator.evaluate(text, translation, source_locale, target_locale, glossary_terms: glossary_terms, platform: platform)
52
+ @quality_checks[target_locale] = result
53
+ report_quality_check(target_locale, result)
54
+ end
55
+ translation
56
+ end
57
+
58
+ private
59
+
60
+ def report_quality_check(locale, result)
61
+ if result[:status] == 'unavailable'
62
+ UI.important("Jev #{locale}: check unavailable (#{result[:error]}); translation retained.")
63
+ elsif result[:warnings].any?
64
+ findings = result[:warnings].map { |name, probability| "#{name}=#{format('%.2f', probability)}" }.join(', ')
65
+ UI.important("Jev #{locale}: quality warning — #{findings}. Translation retained.")
66
+ else
67
+ UI.message("Jev #{locale}: no quality warnings detected.")
68
+ end
45
69
  end
46
70
 
47
71
  end
@@ -0,0 +1,105 @@
1
+ require 'net/http'
2
+ require 'json'
3
+
4
+ module Fastlane
5
+ module Helper
6
+ # Optional reporting-only checks. Evaluation failures never discard a translation.
7
+ class TranslationEvaluator
8
+ ENDPOINT = 'https://api.typesafe.ai/v1/systemone'.freeze
9
+ DEFAULT_MODEL = 'jev-1.13.0'.freeze
10
+ QUESTIONS = {
11
+ 'added_claim' => 'Does `translation` assert a feature, behavior, benefit, or fix not supported by `source`? Compare meaning across languages. Natural paraphrases and grammatical variants are not added claims. A direct reversal belongs to contradiction, not this check.',
12
+ 'omission' => 'Does `translation` omit an entire material feature, fix, benefit, or availability statement from `source`? Concise paraphrases and summaries are allowed under `policy`; preserving all material points in fewer words is not an omission. Direct contradictions belong to the contradiction check.',
13
+ 'contradiction' => 'Does `translation` make a statement with meaning directly contradicting `source`, such as reversing availability or saying a feature was removed when it was added? Compare meaning across languages.',
14
+ 'wrong_language' => 'Is the prose in `translation` in a language other than the language specified by `target_locale`, or are full source-language sentences left untranslated? Product names and short technical terms are exempt.',
15
+ 'glossary_error' => 'Does `translation` use a different UI feature name for a source term in `glossary` instead of the designated localized name? Allow capitalization, grammatical inflection, case endings, and agreement. Judge UI-name identity, not exact substring equality. If the term was omitted entirely, do not flag this check.'
16
+ }.freeze
17
+
18
+ def initialize(params)
19
+ @api_key = params[:typesafe_api_key].to_s.strip
20
+ @api_key = ENV['TYPESAFE_API_KEY'].to_s.strip if @api_key.empty?
21
+ @model = params[:jev_model] || DEFAULT_MODEL
22
+ @threshold = params[:jev_threshold] || 0.5
23
+ @timeout = params[:jev_timeout] || 10
24
+ end
25
+
26
+ def evaluate(source, translation, source_locale, target_locale, glossary_terms: {}, platform: nil)
27
+ return unavailable('TYPESAFE_API_KEY is not configured') if @api_key.to_s.strip.empty?
28
+
29
+ state = {
30
+ source: source, translation: translation, source_locale: source_locale,
31
+ target_locale: target_locale, glossary: glossary_terms, platform: platform,
32
+ policy: platform == 'android' ?
33
+ 'Android requires at most 500 characters. Summarize concisely and omit minor wording, while retaining every material feature, fix, and availability claim.' :
34
+ 'Preserve every material claim. Natural paraphrase and grammatical inflection are allowed.'
35
+ }
36
+ payload = {
37
+ model: @model, state: state,
38
+ questions: QUESTIONS.transform_values { |instructions| { type: 'noul', instructions: instructions } }
39
+ }
40
+ response = request(payload)
41
+ return unavailable("HTTP #{response.code}") unless response.is_a?(Net::HTTPSuccess)
42
+
43
+ parse_result(JSON.parse(response.body))
44
+ rescue StandardError => e
45
+ # Exception messages and API bodies can contain credentials or source text.
46
+ unavailable("request failed (#{e.class})")
47
+ end
48
+
49
+ private
50
+
51
+ def request(payload)
52
+ uri = URI(ENDPOINT)
53
+ http = Net::HTTP.new(uri.host, uri.port)
54
+ http.use_ssl = true
55
+ http.open_timeout = @timeout
56
+ http.read_timeout = @timeout
57
+ http.write_timeout = @timeout
58
+ request = Net::HTTP::Post.new(uri.request_uri)
59
+ request['Authorization'] = "Bearer #{@api_key}"
60
+ request['Content-Type'] = 'application/json'
61
+ request.body = JSON.generate(payload)
62
+
63
+ # Bound retries and delay so a checker outage does not stall a release lane.
64
+ response = nil
65
+ 3.times do |attempt|
66
+ response = http.request(request)
67
+ break unless %w[429 529].include?(response.code) && attempt < 2
68
+
69
+ delay = response['Retry-After'].to_f
70
+ delay = 0.5 * (2**attempt) if delay <= 0
71
+ # Respect a longer server delay by ending this optional check instead.
72
+ break if delay > 2.0
73
+
74
+ sleep(delay)
75
+ end
76
+ response
77
+ end
78
+
79
+ def parse_result(result)
80
+ return unavailable('invalid response') unless result.is_a?(Hash) && result['model'].is_a?(String) && !result['model'].empty?
81
+
82
+ answers = result['answers']
83
+ return unavailable('invalid response') unless answers.is_a?(Hash)
84
+
85
+ probabilities = {}
86
+ QUESTIONS.each_key do |name|
87
+ answer = answers[name]
88
+ value = answer['noul'] if answer.is_a?(Hash)
89
+ unless answer.is_a?(Hash) && answer['type'] == 'noul' && value.is_a?(Numeric) && value.finite? && value.between?(0, 1)
90
+ return unavailable('invalid response')
91
+ end
92
+ probabilities[name] = value
93
+ end
94
+ {
95
+ status: 'checked', model: result['model'], probabilities: probabilities,
96
+ warnings: probabilities.select { |_name, probability| probability >= @threshold }
97
+ }
98
+ end
99
+
100
+ def unavailable(reason)
101
+ { status: 'unavailable', error: reason }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module TranslateGptReleaseNotes
3
- VERSION = "0.4.1"
3
+ VERSION = "0.5.0"
4
4
  end
5
5
  end
@@ -4,7 +4,9 @@ module Fastlane
4
4
  module TranslateGptReleaseNotes
5
5
  # Return all .rb files inside the "actions" and "helper" directory
6
6
  def self.all_classes
7
- Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))]
7
+ Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))].reject do |path|
8
+ File.basename(path).include?('.sync-conflict-')
9
+ end
8
10
  end
9
11
  end
10
12
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fastlane-plugin-translate_gpt_release_notes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Karliner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-01 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby-openai
@@ -240,6 +240,7 @@ executables: []
240
240
  extensions: []
241
241
  extra_rdoc_files: []
242
242
  files:
243
+ - CHANGELOG.md
243
244
  - LICENSE
244
245
  - README.md
245
246
  - lib/fastlane/plugin/translate_gpt_release_notes.rb
@@ -253,6 +254,7 @@ files:
253
254
  - lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/openai_provider.rb
254
255
  - lib/fastlane/plugin/translate_gpt_release_notes/helper/providers/provider_factory.rb
255
256
  - lib/fastlane/plugin/translate_gpt_release_notes/helper/translate_gpt_release_notes_helper.rb
257
+ - lib/fastlane/plugin/translate_gpt_release_notes/helper/translation_evaluator.rb
256
258
  - lib/fastlane/plugin/translate_gpt_release_notes/version.rb
257
259
  homepage: https://github.com/antonkarliner/fastlane-plugin-translate_gpt_release_notes
258
260
  licenses: