fastlane-plugin-wpmreleasetoolkit 14.9.0 → 14.11.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 +4 -4
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb +144 -0
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localizations.rb +12 -7
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb +146 -0
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/github_helper.rb +19 -1
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb +43 -15
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb +214 -11
- data/lib/fastlane/plugin/wpmreleasetoolkit/version.rb +1 -1
- metadata +9 -151
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80e9dbdf5a3843f12c90985ff55ff40660ef4f7e2346119992c9d344c9b90697
|
|
4
|
+
data.tar.gz: 495ce50a196928419b024c54647f1656c731aaabb5bfba446d1817baec978d45
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 53c1b51793d9cafaea27b5af8ddd0ec92a4b29ea2942078102e6bdf6007d21b92ed5c2910a52d1796f46c400babfd1f44ea53e4a0671f07ea06e7a6e4de97192
|
|
7
|
+
data.tar.gz: 1d65caa4489b6f54e2384bc939ddd9b8bd0fd50933fecf353f7e86d3eda1240891633769d6a229d14627fb8f387d590f0a9a3880a362371d48329fdce1338a20
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fastlane/action'
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
|
|
6
|
+
module Fastlane
|
|
7
|
+
module Actions
|
|
8
|
+
class AndroidPruneOrphanedTranslationsAction < Action
|
|
9
|
+
# Matches an Android `values-<qualifier>` directory whose qualifier is a locale: a 2- or 3-letter language
|
|
10
|
+
# code (`fr`, `kmr`), an optional region (`pt-rBR`, `es-r419`), or a BCP-47 `b+` form (`b+sr+Latn`), so
|
|
11
|
+
# non-locale qualifier dirs (e.g. `values-night`, `values-v21`, `values-land`) are left untouched.
|
|
12
|
+
LOCALE_VALUES_DIR_REGEX = /\Avalues-(?:b\+[a-zA-Z]+(?:\+[a-zA-Z0-9]+)*|[a-z]{2,3}(?:-r(?:[A-Z]{2}|\d{3}))?)\z/
|
|
13
|
+
|
|
14
|
+
# Android UI-mode qualifiers, which are never locales:
|
|
15
|
+
# https://developer.android.com/guide/topics/resources/providing-resources#UiModeQualifier
|
|
16
|
+
# `car` is indistinguishable by shape from a 3-letter ISO 639 language code, so `LOCALE_VALUES_DIR_REGEX`
|
|
17
|
+
# would otherwise treat `values-car` as a locale and prune its (valid) car-mode resources. There's no purely
|
|
18
|
+
# syntactic way to tell the two apart, so we exclude the documented UI-mode qualifiers explicitly. (`car` is
|
|
19
|
+
# the only one short enough to currently match the regex; the rest are listed to capture the full set.)
|
|
20
|
+
UI_MODE_QUALIFIERS = %w[car desk television appliance watch vrheadset].freeze
|
|
21
|
+
|
|
22
|
+
DEFAULT_SOURCE_STRINGS_FILE_NAME = 'strings.xml'
|
|
23
|
+
DEFAULT_SOURCE_STRINGS_RELATIVE_PATH = File.join('values', DEFAULT_SOURCE_STRINGS_FILE_NAME).freeze
|
|
24
|
+
|
|
25
|
+
def self.run(params)
|
|
26
|
+
res_dir = params[:res_dir]
|
|
27
|
+
source_paths = [File.join(res_dir, DEFAULT_SOURCE_STRINGS_RELATIVE_PATH)] + params[:additional_source_strings_paths]
|
|
28
|
+
valid_keys = collect_keys(source_paths)
|
|
29
|
+
|
|
30
|
+
locale_files = Dir.glob(File.join(res_dir, 'values-*', DEFAULT_SOURCE_STRINGS_FILE_NAME)).select do |file|
|
|
31
|
+
dir_name = File.basename(File.dirname(file))
|
|
32
|
+
dir_name.match?(LOCALE_VALUES_DIR_REGEX) && !UI_MODE_QUALIFIERS.include?(dir_name.delete_prefix('values-'))
|
|
33
|
+
end
|
|
34
|
+
total_pruned = 0
|
|
35
|
+
|
|
36
|
+
locale_files.each do |file|
|
|
37
|
+
pruned = prune_file(file: file, valid_keys: valid_keys)
|
|
38
|
+
next if pruned.empty?
|
|
39
|
+
|
|
40
|
+
total_pruned += pruned.count
|
|
41
|
+
UI.message("Pruned #{pruned.count} orphaned entries from `#{file}`: #{pruned.join(', ')}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
UI.success("Pruned #{total_pruned} orphaned translation entries across #{locale_files.count} locale file(s).")
|
|
45
|
+
total_pruned
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Collects the set of resource names (string, string-array, plurals, …) declared in the given strings files.
|
|
49
|
+
#
|
|
50
|
+
# @param [Array<String>] paths The strings.xml files to read the valid keys from.
|
|
51
|
+
# @return [Set<String>] The set of declared resource names.
|
|
52
|
+
def self.collect_keys(paths)
|
|
53
|
+
paths.each_with_object(Set.new) do |path, keys|
|
|
54
|
+
doc = File.open(path) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
|
|
55
|
+
doc.xpath('/resources/*[@name]').each { |node| keys << node['name'] }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Removes from `file` any resource entry whose `name` is not in `valid_keys`, preserving the rest of the
|
|
60
|
+
# file's formatting (so the change shows up as a minimal diff).
|
|
61
|
+
#
|
|
62
|
+
# @param [String] file The locale strings.xml file to prune.
|
|
63
|
+
# @param [Set<String>] valid_keys The set of names that are allowed to remain.
|
|
64
|
+
# @return [Array<String>] The names of the entries that were pruned.
|
|
65
|
+
def self.prune_file(file:, valid_keys:)
|
|
66
|
+
doc = File.open(file) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
|
|
67
|
+
orphans = doc.xpath('/resources/*[@name]').reject { |node| valid_keys.include?(node['name']) }
|
|
68
|
+
return [] if orphans.empty?
|
|
69
|
+
|
|
70
|
+
names = orphans.map { |node| node['name'] }
|
|
71
|
+
orphans.each do |node|
|
|
72
|
+
# Drop the indentation/newline text node right before the element too, to avoid leaving a blank line.
|
|
73
|
+
previous = node.previous_sibling
|
|
74
|
+
previous.remove if previous&.text? && previous.text.strip.empty?
|
|
75
|
+
node.remove
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
File.open(file, 'w') { |f| doc.write_to(f, encoding: Encoding::UTF_8.to_s, indent: 4) }
|
|
79
|
+
names
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#####################################################
|
|
83
|
+
# @!group Documentation
|
|
84
|
+
#####################################################
|
|
85
|
+
|
|
86
|
+
def self.description
|
|
87
|
+
'Removes translations whose keys are not present in the source strings, to avoid Lint `ExtraTranslation` errors'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.details
|
|
91
|
+
<<~DETAILS
|
|
92
|
+
When downloading translations from GlotPress, the export may include keys that are no longer present in
|
|
93
|
+
the app's source strings (e.g. removed or renamed since the GlotPress source was last synced). Android
|
|
94
|
+
Lint flags these orphaned translations as `ExtraTranslation` errors.
|
|
95
|
+
|
|
96
|
+
This action removes — from every `values-*/strings.xml` under `res_dir` — any `<string>`, `<string-array>`
|
|
97
|
+
or `<plurals>` entry whose `name` is not declared in the default `values/strings.xml` of `res_dir`,
|
|
98
|
+
optionally unioned with `additional_source_strings_paths` (useful when a product flavor overlays a base
|
|
99
|
+
module's resources at build time, so the base module's keys are valid too).
|
|
100
|
+
DETAILS
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.available_options
|
|
104
|
+
[
|
|
105
|
+
FastlaneCore::ConfigItem.new(
|
|
106
|
+
key: :res_dir,
|
|
107
|
+
description: "Path to the Android project's `res` directory containing the `values-*` locale subdirectories to prune",
|
|
108
|
+
type: String,
|
|
109
|
+
optional: false,
|
|
110
|
+
verify_block: proc do |value|
|
|
111
|
+
source_path = File.join(value, DEFAULT_SOURCE_STRINGS_RELATIVE_PATH)
|
|
112
|
+
UI.user_error!("Source strings file not found: `#{source_path}`") unless File.file?(source_path)
|
|
113
|
+
end
|
|
114
|
+
),
|
|
115
|
+
FastlaneCore::ConfigItem.new(
|
|
116
|
+
key: :additional_source_strings_paths,
|
|
117
|
+
description: 'Paths to additional default `strings.xml` files whose keys should also be treated as valid ' \
|
|
118
|
+
'(e.g. a base module that the pruned `res_dir` overlays at build time)',
|
|
119
|
+
type: Array,
|
|
120
|
+
optional: true,
|
|
121
|
+
default_value: [],
|
|
122
|
+
verify_block: proc do |value|
|
|
123
|
+
value.each do |path|
|
|
124
|
+
UI.user_error!("Source strings file not found: `#{path}`") unless File.file?(path)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
),
|
|
128
|
+
]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.return_value
|
|
132
|
+
'The total number of orphaned translation entries that were pruned'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.authors
|
|
136
|
+
['Automattic']
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.is_supported?(platform)
|
|
140
|
+
platform == :android
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -56,15 +56,20 @@ module Fastlane
|
|
|
56
56
|
language = File.basename(File.dirname(file), '.lproj')
|
|
57
57
|
path = File.join(params[:input_dir], file)
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
duplicate_keys[language] =
|
|
63
|
-
|
|
59
|
+
status, payload = Fastlane::Helper::Ios::StringsFileValidationHelper.scan_for_duplicate_keys(file: path)
|
|
60
|
+
case status
|
|
61
|
+
when :scanned
|
|
62
|
+
duplicate_keys[language] = payload.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless payload.empty?
|
|
63
|
+
when :unsupported_format
|
|
64
64
|
UI.important <<~WRONG_FORMAT
|
|
65
|
-
File `#{path}` is in #{
|
|
66
|
-
Since your files are in #{
|
|
65
|
+
File `#{path}` is in #{payload} format, while finding duplicate keys can only occur on files that are in ASCII-plist format.
|
|
66
|
+
Since your files are in #{payload} format, you should probably disable the `check_duplicate_keys` option from this `#{action_name}` call.
|
|
67
67
|
WRONG_FORMAT
|
|
68
|
+
when :unscannable
|
|
69
|
+
UI.important <<~UNSCANNABLE
|
|
70
|
+
Could not check `#{path}` for duplicate keys: #{payload.strip}
|
|
71
|
+
The file parses as a property list but isn't a flat `.strings` file the duplicate-key scanner understands, so duplicate detection was skipped for it.
|
|
72
|
+
UNSCANNABLE
|
|
68
73
|
end
|
|
69
74
|
end
|
|
70
75
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fastlane/action'
|
|
4
|
+
require 'fastlane_core/ui/ui'
|
|
5
|
+
|
|
6
|
+
module Fastlane
|
|
7
|
+
module Actions
|
|
8
|
+
class MacosVerifyCodeSigningAction < Action
|
|
9
|
+
def self.run(params)
|
|
10
|
+
paths = params[:artifact_path]
|
|
11
|
+
UI.user_error!('No artifact to verify: `artifact_path` is empty') if paths.empty?
|
|
12
|
+
|
|
13
|
+
paths.each do |path|
|
|
14
|
+
UI.user_error!("There is no artifact at #{path}") unless File.exist?(path)
|
|
15
|
+
|
|
16
|
+
UI.message("Verifying #{path}")
|
|
17
|
+
|
|
18
|
+
case File.extname(path).downcase
|
|
19
|
+
when '.app'
|
|
20
|
+
verify_app_bundle(path: path, expected_authority: params[:expected_authority], verify_notarization: params[:verify_notarization])
|
|
21
|
+
when '.dmg'
|
|
22
|
+
verify_disk_image(path: path, expected_authority: params[:expected_authority], verify_notarization: params[:verify_notarization])
|
|
23
|
+
else
|
|
24
|
+
UI.user_error!("Don't know how to verify #{path}. Supported artifacts are `.app` bundles and `.dmg` disk images")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
UI.success("Verified #{paths.length} artifact(s)")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.verify_app_bundle(path:, expected_authority:, verify_notarization:)
|
|
32
|
+
UI.user_error!("#{path} is not signed at all") unless signed?(path, deep: true)
|
|
33
|
+
verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil?
|
|
34
|
+
|
|
35
|
+
return unless verify_notarization
|
|
36
|
+
|
|
37
|
+
verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'execute', '--verbose=2', path)
|
|
38
|
+
verify!("#{path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Unlike an app bundle, a disk image is often not signed at all — `electron-builder`, for one,
|
|
42
|
+
# only signs the app inside it. Gatekeeper then rejects the image itself with `no usable
|
|
43
|
+
# signature` even when it carries a notarization ticket, so the stapled ticket is the only
|
|
44
|
+
# check that means anything for an unsigned image.
|
|
45
|
+
#
|
|
46
|
+
def self.verify_disk_image(path:, expected_authority:, verify_notarization:)
|
|
47
|
+
# Said up front because `sh` logs a non-zero exit in red regardless of it being handled,
|
|
48
|
+
# which reads as a broken build in the CI log of an otherwise passing job.
|
|
49
|
+
UI.message("Checking whether #{path} is signed. Disk images usually aren't, so a `codesign` failure due to the image being unsigned is expected and tolerated (other signature failures will still fail).")
|
|
50
|
+
|
|
51
|
+
if signed?(path)
|
|
52
|
+
verify_authority!(path: path, expected_authority: expected_authority) unless expected_authority.nil?
|
|
53
|
+
verify!("#{path} was rejected by Gatekeeper", 'spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', path) if verify_notarization
|
|
54
|
+
else
|
|
55
|
+
UI.important("#{path} is not signed — skipping its signature checks. We expect that the app it contains is the one carrying the signature.")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
verify!("#{path} has no notarization ticket stapled to it", 'xcrun', 'stapler', 'validate', path) if verify_notarization
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Distinguishes an artifact that carries no signature at all from one whose signature is
|
|
62
|
+
# broken: the former is expected for a disk image, the latter always a failure.
|
|
63
|
+
#
|
|
64
|
+
# @param deep [Boolean] Whether to also verify the nested code an app bundle embeds.
|
|
65
|
+
#
|
|
66
|
+
def self.signed?(path, deep: false)
|
|
67
|
+
command = ['codesign', '--verify']
|
|
68
|
+
command << '--deep' if deep
|
|
69
|
+
command += ['--strict', '--verbose=2', path]
|
|
70
|
+
|
|
71
|
+
exitstatus, output = sh(*command) { |status, result, _| [status.exitstatus, result] }
|
|
72
|
+
|
|
73
|
+
return true if exitstatus.zero?
|
|
74
|
+
return false if output.include?('not signed at all')
|
|
75
|
+
|
|
76
|
+
UI.user_error!("The code signature of #{path} is not valid:\n#{output}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.verify!(error_message, *command)
|
|
80
|
+
sh(*command, error_callback: ->(_) { UI.user_error!(error_message) })
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.verify_authority!(path:, expected_authority:)
|
|
84
|
+
details = sh('codesign', '--display', '--verbose=2', path)
|
|
85
|
+
return if details.include?("Authority=#{expected_authority}")
|
|
86
|
+
|
|
87
|
+
UI.user_error!("#{path} is not signed by '#{expected_authority}':\n#{details}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#####################################################
|
|
91
|
+
# @!group Documentation
|
|
92
|
+
#####################################################
|
|
93
|
+
|
|
94
|
+
def self.description
|
|
95
|
+
'Verify that macOS artifacts are properly code signed and notarized'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.details
|
|
99
|
+
<<~DETAILS
|
|
100
|
+
Verify that the given macOS artifacts are signed, and optionally notarized.
|
|
101
|
+
|
|
102
|
+
The checks that apply are picked from the artifact's extension:
|
|
103
|
+
|
|
104
|
+
- `.app` — the signature is valid and satisfies its designated requirement, Gatekeeper accepts
|
|
105
|
+
the bundle for execution, and a notarization ticket is stapled to it.
|
|
106
|
+
- `.dmg` — if the image is signed, the signature is valid (and can be checked against the expected authority) and Gatekeeper accepts opening it; when `verify_notarization` is true, a notarization ticket is stapled to the image.
|
|
107
|
+
DETAILS
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.available_options
|
|
111
|
+
[
|
|
112
|
+
FastlaneCore::ConfigItem.new(
|
|
113
|
+
key: :artifact_path,
|
|
114
|
+
description: 'The path, or list of paths, to the `.app` bundle(s) or `.dmg` disk image(s) to verify',
|
|
115
|
+
type: Array,
|
|
116
|
+
verify_block: proc do |value|
|
|
117
|
+
UI.user_error!('`artifact_path` must be a String or an Array of Strings') unless value.all?(String)
|
|
118
|
+
end
|
|
119
|
+
),
|
|
120
|
+
FastlaneCore::ConfigItem.new(
|
|
121
|
+
key: :expected_authority,
|
|
122
|
+
description: 'The signing authority the artifact is expected to be signed by, e.g. `Developer ID Application: ACME, Inc. (ABCDE12345)`. ' \
|
|
123
|
+
+ 'When omitted, any valid signature is accepted',
|
|
124
|
+
type: String,
|
|
125
|
+
optional: true,
|
|
126
|
+
default_value: nil
|
|
127
|
+
),
|
|
128
|
+
FastlaneCore::ConfigItem.new(
|
|
129
|
+
key: :verify_notarization,
|
|
130
|
+
description: 'Whether to also assert that the artifact is accepted by Gatekeeper and has a notarization ticket stapled to it',
|
|
131
|
+
type: Boolean,
|
|
132
|
+
default_value: true
|
|
133
|
+
),
|
|
134
|
+
]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.authors
|
|
138
|
+
['Automattic']
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.is_supported?(platform)
|
|
142
|
+
platform == :mac
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -200,7 +200,7 @@ module Fastlane
|
|
|
200
200
|
# @raise [Fastlane::UI::Error] UI.user_error! if the release does not exist.
|
|
201
201
|
#
|
|
202
202
|
def get_release(repository:, version:)
|
|
203
|
-
release =
|
|
203
|
+
release = find_release(repository: repository, version: version)
|
|
204
204
|
return release unless release.nil?
|
|
205
205
|
|
|
206
206
|
UI.user_error!("Could not find GitHub Release for tag #{version} in #{repository}")
|
|
@@ -244,6 +244,24 @@ module Fastlane
|
|
|
244
244
|
release.html_url
|
|
245
245
|
end
|
|
246
246
|
|
|
247
|
+
def find_release(repository:, version:)
|
|
248
|
+
release = client.releases(repository).find { |candidate| candidate.tag_name == version }
|
|
249
|
+
return release unless release.nil?
|
|
250
|
+
|
|
251
|
+
release_for_tag(repository: repository, version: version)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def release_for_tag(repository:, version:)
|
|
255
|
+
client.release_for_tag(repository, version)
|
|
256
|
+
rescue Octokit::NotFound
|
|
257
|
+
# A 404 only means the fallback did not find a release for this tag.
|
|
258
|
+
# Other API errors should be surfaced.
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private :find_release
|
|
263
|
+
private :release_for_tag
|
|
264
|
+
|
|
247
265
|
# Use the GitHub API to generate release notes based on the list of PRs between current tag and previous tag.
|
|
248
266
|
# @note This API uses the `.github/release.yml` config file to classify the PRs by category in the generated list according to PR labels.
|
|
249
267
|
#
|
|
@@ -15,18 +15,24 @@ module Fastlane
|
|
|
15
15
|
# Returns the type of a `.strings` file (XML, binary or ASCII)
|
|
16
16
|
#
|
|
17
17
|
# @param [String] path The path to the `.strings` file to check
|
|
18
|
+
# @param [Boolean] assume_valid Skip the `plutil -lint` validity check when the caller has already
|
|
19
|
+
# confirmed the file parses (e.g. via `read_strings_file_as_hash`), avoiding a redundant
|
|
20
|
+
# `plutil` invocation. Only the format detection (`file`) then runs.
|
|
18
21
|
# @return [Symbol] The file format used by the `.strings` file. Can be one of:
|
|
19
22
|
# - `:text` for the ASCII-plist file format (containing typical `"key" = "value";` lines)
|
|
20
23
|
# - `:xml` for XML plist file format (can be used if machine-generated, especially since there's no official way/tool to generate the ASCII-plist file format as output)
|
|
21
24
|
# - `:binary` for binary plist file format (usually only true for `.strings` files converted by Xcode at compile time and included in the final `.app`/`.ipa`)
|
|
22
25
|
# - `nil` if the file does not exist or is neither of those format (e.g. not a `.strings` file at all)
|
|
23
26
|
#
|
|
24
|
-
def self.strings_file_type(path:)
|
|
27
|
+
def self.strings_file_type(path:, assume_valid: false)
|
|
25
28
|
return :text if File.empty?(path) # If completely empty file, consider it as a valid `.strings` files in textual format
|
|
26
29
|
|
|
27
|
-
# Start by checking it seems like a valid property-list file (and not e.g. an image or plain text file)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
# Start by checking it seems like a valid property-list file (and not e.g. an image or plain text file).
|
|
31
|
+
# A caller that has already parsed the file can skip this redundant check via `assume_valid: true`.
|
|
32
|
+
unless assume_valid
|
|
33
|
+
_, status = Open3.capture2('/usr/bin/plutil', '-lint', path)
|
|
34
|
+
return nil unless status.success?
|
|
35
|
+
end
|
|
30
36
|
|
|
31
37
|
# If it is a valid property-list file, determine the actual format used
|
|
32
38
|
format_desc, status = Open3.capture2('/usr/bin/file', path)
|
|
@@ -72,6 +78,11 @@ module Fastlane
|
|
|
72
78
|
# @note The method is able to handle input files which are using different encodings,
|
|
73
79
|
# guessing the encoding of each input file using the BOM (and defaulting to UTF8).
|
|
74
80
|
# The generated file will always be in utf-8, by convention.
|
|
81
|
+
# @note Dictionary- and array-valued entries (`"k" = { … };`, `"k" = ( … );`, nesting allowed) are
|
|
82
|
+
# prefixed on their outer key with the value preserved verbatim. If a file still holds some
|
|
83
|
+
# construct the tokenizer can't rewrite, its lines are copied through unprefixed with a warning
|
|
84
|
+
# (and its keys are then bookkept unprefixed too, so the reported duplicates stay accurate)
|
|
85
|
+
# rather than aborting the whole merge.
|
|
75
86
|
#
|
|
76
87
|
# @raise [RuntimeError] If one of the paths provided is not in text format (but XML or binary instead), or if any of the files are missing.
|
|
77
88
|
#
|
|
@@ -88,20 +99,37 @@ module Fastlane
|
|
|
88
99
|
raise "The file `#{input_file}` does not exist or is of unknown format." if fmt.nil?
|
|
89
100
|
raise "The file `#{input_file}` is in #{fmt} format but we currently only support merging `.strings` files in text format." unless fmt == :text
|
|
90
101
|
|
|
91
|
-
|
|
92
|
-
duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list
|
|
93
|
-
all_keys_found += string_keys
|
|
102
|
+
raw_keys = read_strings_file_as_hash(path: input_file).keys
|
|
94
103
|
|
|
95
104
|
tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n")
|
|
96
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
# Add the prefix to every key. We tokenize via `StringsFileValidationHelper.prefix_keys` rather than
|
|
106
|
+
# matching keys with a line-based regex, so that keys are found regardless of where `.strings` comments
|
|
107
|
+
# sit (e.g. `CFBundleName /* note */ = WordPress;`) and `key = value`-looking text inside a comment is
|
|
108
|
+
# left alone. It also handles dictionary/array values (`"k" = { … };`) — prefixing the outer key and
|
|
109
|
+
# copying the value verbatim.
|
|
110
|
+
lines = read_utf8_lines(input_file)
|
|
111
|
+
applied_prefix = prefix
|
|
112
|
+
begin
|
|
113
|
+
lines = Fastlane::Helper::Ios::StringsFileValidationHelper.prefix_keys(lines: lines, prefix: prefix)
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
# `plutil` may still accept a construct the tokenizer can't rewrite: it parses fine (so the file
|
|
116
|
+
# clears the `:text` gate above) yet `prefix_keys` raises on it. Fail soft: copy this file's lines
|
|
117
|
+
# through unprefixed rather than aborting the whole merge — mirroring the scanner path, where
|
|
118
|
+
# `scan_for_duplicate_keys` returns `:unscannable` instead of crashing the lane. `lines` is untouched
|
|
119
|
+
# by the raise (the assignment above never completes), so it still holds the original file contents,
|
|
120
|
+
# and `applied_prefix` records that the keys went out *unprefixed* so the bookkeeping below matches.
|
|
121
|
+
applied_prefix = ''
|
|
122
|
+
UI.important("Could not add prefix `#{prefix}` to the keys in `#{input_file}` (#{e.message}); copying its lines through unprefixed.")
|
|
104
123
|
end
|
|
124
|
+
|
|
125
|
+
# Bookkeep the keys as they were actually written — prefixed, or unprefixed on the fail-soft path.
|
|
126
|
+
# Doing this *after* the rewrite keeps the reported duplicates consistent with the merged file even
|
|
127
|
+
# when prefixing fell back, so a genuine collision is still surfaced rather than silently collapsed.
|
|
128
|
+
string_keys = raw_keys.map { |k| "#{applied_prefix}#{k}" }
|
|
129
|
+
duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list
|
|
130
|
+
all_keys_found += string_keys
|
|
131
|
+
|
|
132
|
+
lines.each { |line| tmp_file.write(line) }
|
|
105
133
|
tmp_file.write("\n")
|
|
106
134
|
end
|
|
107
135
|
tmp_file.close # ensure we flush the content to disk
|
|
@@ -5,24 +5,101 @@ module Fastlane
|
|
|
5
5
|
module Ios
|
|
6
6
|
class StringsFileValidationHelper
|
|
7
7
|
# context can be one of:
|
|
8
|
-
# :root, :maybe_comment_start, :in_line_comment, :in_block_comment,
|
|
9
|
-
# :maybe_block_comment_end, :in_quoted_key,
|
|
10
|
-
# :after_quoted_key_before_eq, :
|
|
11
|
-
# :in_quoted_value, :after_quoted_value
|
|
12
|
-
|
|
8
|
+
# :root, :maybe_comment_start, :maybe_comment_or_value, :in_line_comment, :in_block_comment,
|
|
9
|
+
# :maybe_block_comment_end, :in_quoted_key, :in_unquoted_key,
|
|
10
|
+
# :after_quoted_key_before_eq, :after_quoted_key_and_eq,
|
|
11
|
+
# :in_quoted_value, :in_unquoted_value, :after_quoted_value
|
|
12
|
+
#
|
|
13
|
+
# `resume_context` holds the context to return to once a comment ends. Comments are valid not only at
|
|
14
|
+
# the top level but also *between* the tokens of a statement (e.g. `"key" /* note */ = "value";`), so a
|
|
15
|
+
# comment must resume the state it interrupted rather than always dropping back to `:root`.
|
|
16
|
+
# `depth` tracks how deeply nested we are inside a container value (`{ … }` / `( … )`); see `:in_container_value`.
|
|
17
|
+
State = Struct.new(:context, :buffer, :in_escaped_ctx, :found_key, :resume_context, :depth)
|
|
18
|
+
|
|
19
|
+
# Characters allowed in an *unquoted* string — a key or a value. Unquoted strings are valid
|
|
20
|
+
# `.strings` syntax (the old-style ASCII property-list format) and are common in `InfoPlist.strings`
|
|
21
|
+
# (e.g. `CFBundleName = WordPress;`). `plutil` accepts alphanumerics plus `_ . - $ : /` in an
|
|
22
|
+
# unquoted string, so we match the same set: this scanner only ever runs on input `plutil` has
|
|
23
|
+
# already accepted, and matching its grammar keeps a file it parses from tripping the scanner.
|
|
24
|
+
# An unquoted key runs until the first whitespace or `=`; an unquoted value until whitespace or `;`.
|
|
25
|
+
UNQUOTED_STRING_CHARACTER = %r{[a-zA-Z0-9_.$:/-]}u
|
|
26
|
+
|
|
27
|
+
# Enter a comment from an inter-token position, remembering where to resume once it ends.
|
|
28
|
+
# (`state.context` is still the originating state when a transition lambda runs — it's only reassigned
|
|
29
|
+
# to the lambda's return value afterwards — so this captures the state the comment interrupts.)
|
|
30
|
+
ENTER_COMMENT = lambda do |state, _c|
|
|
31
|
+
state.resume_context = state.context
|
|
32
|
+
:maybe_comment_start
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A `/` between `=` and the value is ambiguous: it can start a comment (`/* … */` or `// …`) or be the
|
|
36
|
+
# first character of an unquoted value (e.g. a path like `/usr/bin`). Defer the decision by one character
|
|
37
|
+
# via `:maybe_comment_or_value`.
|
|
38
|
+
ENTER_COMMENT_OR_VALUE = lambda do |state, _c|
|
|
39
|
+
state.resume_context = state.context
|
|
40
|
+
:maybe_comment_or_value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Restore the context a comment interrupted (defaults to `:root`), then clear the saved context.
|
|
44
|
+
RESUME_AFTER_COMMENT = lambda do |state, _c|
|
|
45
|
+
resume = state.resume_context || :root
|
|
46
|
+
state.resume_context = :root
|
|
47
|
+
resume
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A value can be a nested container — a dictionary `{ … }` or an array `( … )` — which `plutil` accepts
|
|
51
|
+
# (e.g. `"k" = { a = b; };` or `"k" = ( "a", "b" );`). We don't rewrite or record anything *inside* a
|
|
52
|
+
# container: its inner keys are not top-level keys, and `prefix_keys` copies the value through verbatim.
|
|
53
|
+
# We only need to find the matching close delimiter, so we just count nesting depth. `OPEN_CONTAINER`
|
|
54
|
+
# doubles as the entry transition from `:after_quoted_key_and_eq` and as the nested-open transition.
|
|
55
|
+
OPEN_CONTAINER = lambda do |state, _c|
|
|
56
|
+
state.depth += 1
|
|
57
|
+
:in_container_value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Close one level of nesting. The value is only finished — and we go back to expecting the terminating
|
|
61
|
+
# `;` — once depth returns to 0; otherwise we're still inside an outer container.
|
|
62
|
+
CLOSE_CONTAINER = lambda do |state, _c|
|
|
63
|
+
state.depth -= 1
|
|
64
|
+
state.depth.zero? ? :after_quoted_value : :in_container_value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A `/` inside a container may start a comment — whose body can contain `{ } ( ) ; "` that must NOT count
|
|
68
|
+
# toward nesting — or just be an ordinary value character (e.g. a path). Defer the decision one char,
|
|
69
|
+
# resuming the container in either case.
|
|
70
|
+
ENTER_CONTAINER_COMMENT = lambda do |state, _c|
|
|
71
|
+
state.resume_context = :in_container_value
|
|
72
|
+
:maybe_container_comment
|
|
73
|
+
end
|
|
13
74
|
|
|
14
75
|
TRANSITIONS = {
|
|
15
76
|
root: {
|
|
16
77
|
/\s/u => :root,
|
|
17
78
|
'/' => :maybe_comment_start,
|
|
18
|
-
'"' => :in_quoted_key
|
|
79
|
+
'"' => :in_quoted_key,
|
|
80
|
+
# An unquoted key, e.g. `CFBundleName = "…";` as used by `InfoPlist.strings`.
|
|
81
|
+
UNQUOTED_STRING_CHARACTER => lambda do |state, c|
|
|
82
|
+
state.buffer.write(c)
|
|
83
|
+
:in_unquoted_key
|
|
84
|
+
end
|
|
19
85
|
},
|
|
20
86
|
maybe_comment_start: {
|
|
21
87
|
'/' => :in_line_comment,
|
|
22
88
|
/\*/u => :in_block_comment
|
|
23
89
|
},
|
|
90
|
+
# Reached only from `:after_quoted_key_and_eq`, where a leading `/` might begin a comment or an
|
|
91
|
+
# unquoted value. A `*` or `/` confirms a comment; anything else means the `/` was the value's first
|
|
92
|
+
# character (values aren't buffered, so we just continue scanning the value).
|
|
93
|
+
maybe_comment_or_value: {
|
|
94
|
+
/\*/u => :in_block_comment,
|
|
95
|
+
'/' => :in_line_comment,
|
|
96
|
+
/./mu => lambda do |state, _c|
|
|
97
|
+
state.resume_context = :root
|
|
98
|
+
:in_unquoted_value
|
|
99
|
+
end
|
|
100
|
+
},
|
|
24
101
|
in_line_comment: {
|
|
25
|
-
"\n" =>
|
|
102
|
+
"\n" => RESUME_AFTER_COMMENT,
|
|
26
103
|
/./u => :in_line_comment
|
|
27
104
|
},
|
|
28
105
|
in_block_comment: {
|
|
@@ -30,7 +107,7 @@ module Fastlane
|
|
|
30
107
|
/./mu => :in_block_comment
|
|
31
108
|
},
|
|
32
109
|
maybe_block_comment_end: {
|
|
33
|
-
'/' =>
|
|
110
|
+
'/' => RESUME_AFTER_COMMENT,
|
|
34
111
|
/./mu => :in_block_comment
|
|
35
112
|
},
|
|
36
113
|
in_quoted_key: {
|
|
@@ -44,21 +121,78 @@ module Fastlane
|
|
|
44
121
|
:in_quoted_key
|
|
45
122
|
end
|
|
46
123
|
},
|
|
124
|
+
in_unquoted_key: {
|
|
125
|
+
# The key ends at the first whitespace or `=`. Whitespace still expects an `=` next;
|
|
126
|
+
# an `=` moves straight on to the value.
|
|
127
|
+
/[\s=]/u => lambda do |state, c|
|
|
128
|
+
state.found_key = state.buffer.string.dup
|
|
129
|
+
state.buffer = StringIO.new
|
|
130
|
+
c == '=' ? :after_quoted_key_and_eq : :after_quoted_key_before_eq
|
|
131
|
+
end,
|
|
132
|
+
UNQUOTED_STRING_CHARACTER => lambda do |state, c|
|
|
133
|
+
state.buffer.write(c)
|
|
134
|
+
:in_unquoted_key
|
|
135
|
+
end
|
|
136
|
+
},
|
|
47
137
|
after_quoted_key_before_eq: {
|
|
138
|
+
# A comment may sit between the key and the `=` (e.g. `"key" /* note */ = "value";`).
|
|
139
|
+
'/' => ENTER_COMMENT,
|
|
48
140
|
/\s/u => :after_quoted_key_before_eq,
|
|
49
141
|
'=' => :after_quoted_key_and_eq
|
|
50
142
|
},
|
|
51
143
|
after_quoted_key_and_eq: {
|
|
144
|
+
# A `/` here may start a comment or an unquoted value (which can contain `/`); disambiguate one char
|
|
145
|
+
# later. This entry must precede `UNQUOTED_STRING_CHARACTER` below, which also matches `/`.
|
|
146
|
+
'/' => ENTER_COMMENT_OR_VALUE,
|
|
52
147
|
/\s/u => :after_quoted_key_and_eq,
|
|
53
|
-
'"' => :in_quoted_value
|
|
148
|
+
'"' => :in_quoted_value,
|
|
149
|
+
# A container value — a dictionary `{ … }` or an array `( … )`, which may nest (e.g. `"k" = { a = b; };`).
|
|
150
|
+
/[{(]/u => OPEN_CONTAINER,
|
|
151
|
+
# An unquoted value, e.g. `CFBundleName = WordPress;` as used by `InfoPlist.strings`.
|
|
152
|
+
UNQUOTED_STRING_CHARACTER => :in_unquoted_value
|
|
54
153
|
},
|
|
55
154
|
in_quoted_value: {
|
|
56
155
|
'"' => :after_quoted_value,
|
|
57
156
|
/./mu => :in_quoted_value
|
|
58
157
|
},
|
|
158
|
+
in_unquoted_value: {
|
|
159
|
+
# The value ends at the first whitespace or the terminating `;`. Its contents are irrelevant
|
|
160
|
+
# to duplicate-key detection, so — unlike a key — we don't buffer it.
|
|
161
|
+
';' => :root,
|
|
162
|
+
/\s/u => :after_quoted_value,
|
|
163
|
+
UNQUOTED_STRING_CHARACTER => :in_unquoted_value
|
|
164
|
+
},
|
|
59
165
|
after_quoted_value: {
|
|
166
|
+
# A comment may sit between the value and the terminating `;` (e.g. `"key" = "value" /* note */;`).
|
|
167
|
+
'/' => ENTER_COMMENT,
|
|
60
168
|
/\s/u => :after_quoted_value,
|
|
61
169
|
';' => :root
|
|
170
|
+
},
|
|
171
|
+
# Inside a container value (`{ … }` / `( … )`). We ignore the contents — inner keys aren't top-level
|
|
172
|
+
# keys and the value is copied verbatim by `prefix_keys` — and only track nesting so we can find the
|
|
173
|
+
# matching close. Quoted strings and comments are entered explicitly because their bodies can contain
|
|
174
|
+
# `{ } ( ) ;` that must not affect the depth count; everything else (`=`, `;`, `,`, whitespace, unquoted
|
|
175
|
+
# text, newlines) is consumed by the catch-all, which must stay LAST so the specific keys win first.
|
|
176
|
+
in_container_value: {
|
|
177
|
+
/[{(]/u => OPEN_CONTAINER,
|
|
178
|
+
/[})]/u => CLOSE_CONTAINER,
|
|
179
|
+
'"' => :in_container_quoted_string,
|
|
180
|
+
'/' => ENTER_CONTAINER_COMMENT,
|
|
181
|
+
/./mu => :in_container_value
|
|
182
|
+
},
|
|
183
|
+
# A quoted string inside a container. Skipped wholesale (its `{ } ( ) ; ,` are literal, not structural)
|
|
184
|
+
# until the closing quote returns us to the container. Escapes are handled globally (see the escape
|
|
185
|
+
# branch in `find_duplicated_keys`, whose allow-list includes this context).
|
|
186
|
+
in_container_quoted_string: {
|
|
187
|
+
'"' => :in_container_value,
|
|
188
|
+
/./mu => :in_container_quoted_string
|
|
189
|
+
},
|
|
190
|
+
# One char after a `/` inside a container: `*`/`/` confirm a comment (which resumes the container once
|
|
191
|
+
# it ends), anything else means the `/` was just a value character and we stay in the container.
|
|
192
|
+
maybe_container_comment: {
|
|
193
|
+
/\*/u => :in_block_comment,
|
|
194
|
+
'/' => :in_line_comment,
|
|
195
|
+
/./mu => :in_container_value
|
|
62
196
|
}
|
|
63
197
|
}.freeze
|
|
64
198
|
|
|
@@ -70,7 +204,7 @@ module Fastlane
|
|
|
70
204
|
def self.find_duplicated_keys(file:)
|
|
71
205
|
keys_with_lines = Hash.new { |h, k| h[k] = [] }
|
|
72
206
|
|
|
73
|
-
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil)
|
|
207
|
+
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root, depth: 0)
|
|
74
208
|
|
|
75
209
|
# Using our `each_utf8_line` helper instead of `File.readlines` ensures we can also read files that are
|
|
76
210
|
# encoded in UTF-16, yet process each of their lines as a UTF-8 string, so that `RegExp#match?` don't throw
|
|
@@ -81,7 +215,7 @@ module Fastlane
|
|
|
81
215
|
# This is more straightforward than having to account for it in the `TRANSITIONS` table.
|
|
82
216
|
if state.in_escaped_ctx || c == '\\'
|
|
83
217
|
# Just because we check for escaped characters at the global level, it doesn't mean we allow them in every context.
|
|
84
|
-
allowed_contexts_for_escaped_characters = %i[in_quoted_key in_quoted_value in_block_comment in_line_comment]
|
|
218
|
+
allowed_contexts_for_escaped_characters = %i[in_quoted_key in_quoted_value in_block_comment in_line_comment in_container_quoted_string]
|
|
85
219
|
raise "Found escaped character outside of allowed contexts on line #{line_no + 1} (current context: #{state.context})" unless allowed_contexts_for_escaped_characters.include?(state.context)
|
|
86
220
|
|
|
87
221
|
state.buffer.write(c) if state.context == :in_quoted_key
|
|
@@ -106,6 +240,75 @@ module Fastlane
|
|
|
106
240
|
|
|
107
241
|
keys_with_lines.keep_if { |_, lines| lines.count > 1 }
|
|
108
242
|
end
|
|
243
|
+
|
|
244
|
+
# Detects the file format and, when applicable, scans for duplicate keys — in one step, so
|
|
245
|
+
# callers don't each re-implement the "`:text`-only" gate. `find_duplicated_keys` only
|
|
246
|
+
# understands the flat ASCII-plist syntax; an xml/binary plist can't be tokenized by it (though
|
|
247
|
+
# `plutil` collapses any duplicate to its last value when parsing those anyway).
|
|
248
|
+
#
|
|
249
|
+
# @param [String] file The path to the `.strings` file to inspect.
|
|
250
|
+
# @param [Boolean] assume_valid Forwarded to `strings_file_type`: skip the redundant `plutil -lint`
|
|
251
|
+
# when the caller has already confirmed the file parses.
|
|
252
|
+
# @return [Array] A `[status, payload]` pair, one of:
|
|
253
|
+
# - `[:scanned, { key => [lines] }]` — a `:text` file we tokenized (hash empty if none).
|
|
254
|
+
# - `[:unsupported_format, format]` — not a `:text` file (`:xml`, `:binary`, or `nil`); not scanned.
|
|
255
|
+
# - `[:unscannable, error_message]` — a `:text` file the tokenizer couldn't read.
|
|
256
|
+
# Each caller decides how to react (warn-and-skip, fail closed, …) from this one source of truth.
|
|
257
|
+
def self.scan_for_duplicate_keys(file:, assume_valid: false)
|
|
258
|
+
format = Fastlane::Helper::Ios::L10nHelper.strings_file_type(path: file, assume_valid: assume_valid)
|
|
259
|
+
return [:unsupported_format, format] unless format == :text
|
|
260
|
+
|
|
261
|
+
[:scanned, find_duplicated_keys(file: file)]
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
[:unscannable, e.message]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Rewrites `.strings` lines so every key carries `prefix`, leaving comments, values, whitespace and
|
|
267
|
+
# formatting untouched. A quoted key gets the prefix inside its quotes (`"key"` → `"<prefix>key"`); an
|
|
268
|
+
# unquoted key is wrapped in quotes (`key` → `"<prefix>key"`). Because it tokenizes the file the same way
|
|
269
|
+
# `find_duplicated_keys` does, it is comment-aware: a key sitting behind an inter-token comment (e.g.
|
|
270
|
+
# `key /* note */ = value;`) is still prefixed, and `key = value`-looking text *inside* a comment is left
|
|
271
|
+
# alone — a distinction a line-based regex can't reliably make. It is likewise container-aware: a
|
|
272
|
+
# dictionary or array value (`"k" = { … };` / `"k" = ( … );`, nesting allowed) has only its outer key
|
|
273
|
+
# prefixed, with the value — including any keys *inside* it — copied through verbatim.
|
|
274
|
+
#
|
|
275
|
+
# @param [Array<String>] lines The file's lines, already decoded to UTF-8 (e.g. via `L10nHelper.read_utf8_lines`).
|
|
276
|
+
# @param [String] prefix The prefix to insert before every key. A nil/empty prefix returns `lines` unchanged.
|
|
277
|
+
# @return [Array<String>] The rewritten lines.
|
|
278
|
+
def self.prefix_keys(lines:, prefix:)
|
|
279
|
+
return lines if prefix.nil? || prefix.empty?
|
|
280
|
+
|
|
281
|
+
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil, resume_context: :root, depth: 0)
|
|
282
|
+
lines.map do |line|
|
|
283
|
+
rewritten = +''
|
|
284
|
+
line.each_char do |c|
|
|
285
|
+
# Escaped characters only occur inside quoted strings or comments — never around a key boundary —
|
|
286
|
+
# so they're copied through verbatim (mirroring `find_duplicated_keys`' global escape handling).
|
|
287
|
+
if state.in_escaped_ctx || c == '\\'
|
|
288
|
+
state.in_escaped_ctx = !state.in_escaped_ctx
|
|
289
|
+
rewritten << c
|
|
290
|
+
next
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
previous_context = state.context
|
|
294
|
+
(_, transition) = TRANSITIONS[previous_context].find { |regex, _| c.match?(regex) } || [nil, nil]
|
|
295
|
+
raise "Invalid character `#{c}` found (current context: #{previous_context})" if transition.nil?
|
|
296
|
+
|
|
297
|
+
state.context = transition.is_a?(Proc) ? transition.call(state, c) : transition
|
|
298
|
+
|
|
299
|
+
if previous_context == :root && state.context == :in_quoted_key
|
|
300
|
+
rewritten << c << prefix # opening `"` of a quoted key — the prefix goes inside the quotes
|
|
301
|
+
elsif previous_context == :root && state.context == :in_unquoted_key
|
|
302
|
+
rewritten << '"' << prefix << c # first char of an unquoted key — open a quote + prefix, then the char
|
|
303
|
+
elsif previous_context == :in_unquoted_key && state.context != :in_unquoted_key
|
|
304
|
+
rewritten << '"' << c # the unquoted key just ended — close the quote, then the delimiter
|
|
305
|
+
else
|
|
306
|
+
rewritten << c
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
rewritten
|
|
310
|
+
end
|
|
311
|
+
end
|
|
109
312
|
end
|
|
110
313
|
end
|
|
111
314
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fastlane-plugin-wpmreleasetoolkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 14.
|
|
4
|
+
version: 14.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Automattic
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: buildkit
|
|
@@ -72,14 +71,14 @@ dependencies:
|
|
|
72
71
|
requirements:
|
|
73
72
|
- - "~>"
|
|
74
73
|
- !ruby/object:Gem::Version
|
|
75
|
-
version: '2.
|
|
74
|
+
version: '2.237'
|
|
76
75
|
type: :runtime
|
|
77
76
|
prerelease: false
|
|
78
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
78
|
requirements:
|
|
80
79
|
- - "~>"
|
|
81
80
|
- !ruby/object:Gem::Version
|
|
82
|
-
version: '2.
|
|
81
|
+
version: '2.237'
|
|
83
82
|
- !ruby/object:Gem::Dependency
|
|
84
83
|
name: gettext
|
|
85
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -131,7 +130,7 @@ dependencies:
|
|
|
131
130
|
version: '1.19'
|
|
132
131
|
- - ">="
|
|
133
132
|
- !ruby/object:Gem::Version
|
|
134
|
-
version: 1.19.
|
|
133
|
+
version: 1.19.4
|
|
135
134
|
type: :runtime
|
|
136
135
|
prerelease: false
|
|
137
136
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -141,7 +140,7 @@ dependencies:
|
|
|
141
140
|
version: '1.19'
|
|
142
141
|
- - ">="
|
|
143
142
|
- !ruby/object:Gem::Version
|
|
144
|
-
version: 1.19.
|
|
143
|
+
version: 1.19.4
|
|
145
144
|
- !ruby/object:Gem::Dependency
|
|
146
145
|
name: octokit
|
|
147
146
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -260,147 +259,6 @@ dependencies:
|
|
|
260
259
|
- - "~>"
|
|
261
260
|
- !ruby/object:Gem::Version
|
|
262
261
|
version: '1.31'
|
|
263
|
-
- !ruby/object:Gem::Dependency
|
|
264
|
-
name: activesupport
|
|
265
|
-
requirement: !ruby/object:Gem::Requirement
|
|
266
|
-
requirements:
|
|
267
|
-
- - "~>"
|
|
268
|
-
- !ruby/object:Gem::Version
|
|
269
|
-
version: '8.1'
|
|
270
|
-
type: :development
|
|
271
|
-
prerelease: false
|
|
272
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
273
|
-
requirements:
|
|
274
|
-
- - "~>"
|
|
275
|
-
- !ruby/object:Gem::Version
|
|
276
|
-
version: '8.1'
|
|
277
|
-
- !ruby/object:Gem::Dependency
|
|
278
|
-
name: bundler
|
|
279
|
-
requirement: !ruby/object:Gem::Requirement
|
|
280
|
-
requirements:
|
|
281
|
-
- - "~>"
|
|
282
|
-
- !ruby/object:Gem::Version
|
|
283
|
-
version: '2.0'
|
|
284
|
-
type: :development
|
|
285
|
-
prerelease: false
|
|
286
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
287
|
-
requirements:
|
|
288
|
-
- - "~>"
|
|
289
|
-
- !ruby/object:Gem::Version
|
|
290
|
-
version: '2.0'
|
|
291
|
-
- !ruby/object:Gem::Dependency
|
|
292
|
-
name: fastlane
|
|
293
|
-
requirement: !ruby/object:Gem::Requirement
|
|
294
|
-
requirements:
|
|
295
|
-
- - "~>"
|
|
296
|
-
- !ruby/object:Gem::Version
|
|
297
|
-
version: '2.210'
|
|
298
|
-
type: :development
|
|
299
|
-
prerelease: false
|
|
300
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
301
|
-
requirements:
|
|
302
|
-
- - "~>"
|
|
303
|
-
- !ruby/object:Gem::Version
|
|
304
|
-
version: '2.210'
|
|
305
|
-
- !ruby/object:Gem::Dependency
|
|
306
|
-
name: pry
|
|
307
|
-
requirement: !ruby/object:Gem::Requirement
|
|
308
|
-
requirements:
|
|
309
|
-
- - "~>"
|
|
310
|
-
- !ruby/object:Gem::Version
|
|
311
|
-
version: 0.12.2
|
|
312
|
-
type: :development
|
|
313
|
-
prerelease: false
|
|
314
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
315
|
-
requirements:
|
|
316
|
-
- - "~>"
|
|
317
|
-
- !ruby/object:Gem::Version
|
|
318
|
-
version: 0.12.2
|
|
319
|
-
- !ruby/object:Gem::Dependency
|
|
320
|
-
name: rmagick
|
|
321
|
-
requirement: !ruby/object:Gem::Requirement
|
|
322
|
-
requirements:
|
|
323
|
-
- - "~>"
|
|
324
|
-
- !ruby/object:Gem::Version
|
|
325
|
-
version: '5.3'
|
|
326
|
-
type: :development
|
|
327
|
-
prerelease: false
|
|
328
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
329
|
-
requirements:
|
|
330
|
-
- - "~>"
|
|
331
|
-
- !ruby/object:Gem::Version
|
|
332
|
-
version: '5.3'
|
|
333
|
-
- !ruby/object:Gem::Dependency
|
|
334
|
-
name: rspec
|
|
335
|
-
requirement: !ruby/object:Gem::Requirement
|
|
336
|
-
requirements:
|
|
337
|
-
- - "~>"
|
|
338
|
-
- !ruby/object:Gem::Version
|
|
339
|
-
version: '3.8'
|
|
340
|
-
type: :development
|
|
341
|
-
prerelease: false
|
|
342
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
343
|
-
requirements:
|
|
344
|
-
- - "~>"
|
|
345
|
-
- !ruby/object:Gem::Version
|
|
346
|
-
version: '3.8'
|
|
347
|
-
- !ruby/object:Gem::Dependency
|
|
348
|
-
name: rspec_junit_formatter
|
|
349
|
-
requirement: !ruby/object:Gem::Requirement
|
|
350
|
-
requirements:
|
|
351
|
-
- - "~>"
|
|
352
|
-
- !ruby/object:Gem::Version
|
|
353
|
-
version: 0.4.1
|
|
354
|
-
type: :development
|
|
355
|
-
prerelease: false
|
|
356
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
357
|
-
requirements:
|
|
358
|
-
- - "~>"
|
|
359
|
-
- !ruby/object:Gem::Version
|
|
360
|
-
version: 0.4.1
|
|
361
|
-
- !ruby/object:Gem::Dependency
|
|
362
|
-
name: rubocop
|
|
363
|
-
requirement: !ruby/object:Gem::Requirement
|
|
364
|
-
requirements:
|
|
365
|
-
- - "~>"
|
|
366
|
-
- !ruby/object:Gem::Version
|
|
367
|
-
version: '1.65'
|
|
368
|
-
type: :development
|
|
369
|
-
prerelease: false
|
|
370
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
371
|
-
requirements:
|
|
372
|
-
- - "~>"
|
|
373
|
-
- !ruby/object:Gem::Version
|
|
374
|
-
version: '1.65'
|
|
375
|
-
- !ruby/object:Gem::Dependency
|
|
376
|
-
name: rubocop-rspec
|
|
377
|
-
requirement: !ruby/object:Gem::Requirement
|
|
378
|
-
requirements:
|
|
379
|
-
- - '='
|
|
380
|
-
- !ruby/object:Gem::Version
|
|
381
|
-
version: '3.0'
|
|
382
|
-
type: :development
|
|
383
|
-
prerelease: false
|
|
384
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
385
|
-
requirements:
|
|
386
|
-
- - '='
|
|
387
|
-
- !ruby/object:Gem::Version
|
|
388
|
-
version: '3.0'
|
|
389
|
-
- !ruby/object:Gem::Dependency
|
|
390
|
-
name: simplecov
|
|
391
|
-
requirement: !ruby/object:Gem::Requirement
|
|
392
|
-
requirements:
|
|
393
|
-
- - "~>"
|
|
394
|
-
- !ruby/object:Gem::Version
|
|
395
|
-
version: 0.16.1
|
|
396
|
-
type: :development
|
|
397
|
-
prerelease: false
|
|
398
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
399
|
-
requirements:
|
|
400
|
-
- - "~>"
|
|
401
|
-
- !ruby/object:Gem::Version
|
|
402
|
-
version: 0.16.1
|
|
403
|
-
description:
|
|
404
262
|
email: mobile@automattic.com
|
|
405
263
|
executables: []
|
|
406
264
|
extensions: []
|
|
@@ -421,6 +279,7 @@ files:
|
|
|
421
279
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_download_translations_action.rb
|
|
422
280
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_firebase_test.rb
|
|
423
281
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_launch_emulator_action.rb
|
|
282
|
+
- lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb
|
|
424
283
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_send_app_size_metrics.rb
|
|
425
284
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_shutdown_emulator_action.rb
|
|
426
285
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_update_release_notes.rb
|
|
@@ -476,6 +335,7 @@ files:
|
|
|
476
335
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_send_app_size_metrics.rb
|
|
477
336
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb
|
|
478
337
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_release_notes.rb
|
|
338
|
+
- lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb
|
|
479
339
|
- lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb
|
|
480
340
|
- lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_emulator_helper.rb
|
|
481
341
|
- lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_localize_helper.rb
|
|
@@ -531,7 +391,6 @@ homepage: https://github.com/wordpress-mobile/release-toolkit
|
|
|
531
391
|
licenses:
|
|
532
392
|
- MIT
|
|
533
393
|
metadata: {}
|
|
534
|
-
post_install_message:
|
|
535
394
|
rdoc_options: []
|
|
536
395
|
require_paths:
|
|
537
396
|
- lib
|
|
@@ -546,8 +405,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
546
405
|
- !ruby/object:Gem::Version
|
|
547
406
|
version: '0'
|
|
548
407
|
requirements: []
|
|
549
|
-
rubygems_version: 3.
|
|
550
|
-
signing_key:
|
|
408
|
+
rubygems_version: 3.6.9
|
|
551
409
|
specification_version: 4
|
|
552
410
|
summary: Fastlane plugin for release automation
|
|
553
411
|
test_files: []
|