fastlane-plugin-wpmreleasetoolkit 14.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1db5468c63b1d9cec762c5af38e8cb1e3b858214fb15d1a1d2fd3502da09b9cc
4
- data.tar.gz: 9fc9c2104373e1161421e38a014af1b7a686a72747febc8284d7acca2b06ba25
3
+ metadata.gz: 80e9dbdf5a3843f12c90985ff55ff40660ef4f7e2346119992c9d344c9b90697
4
+ data.tar.gz: 495ce50a196928419b024c54647f1656c731aaabb5bfba446d1817baec978d45
5
5
  SHA512:
6
- metadata.gz: a984608e0fd91a036759a8700d663aca874af9105732f175c42fa97e4e75a2dca1daa0bbcfa0c79caf77ed4463ddc76a4eaf78a6c902220b0df060337a692a7d
7
- data.tar.gz: f97d44bb69c2f8283c061f65d3dac7bffb15ea7fd849ee5f3b3db8795e2129d19b8ad4091871e144f7706f3796678b476c856c2fe01f8aaa8f25040f1cb329eb
6
+ metadata.gz: 53c1b51793d9cafaea27b5af8ddd0ec92a4b29ea2942078102e6bdf6007d21b92ed5c2910a52d1796f46c400babfd1f44ea53e4a0671f07ea06e7a6e4de97192
7
+ data.tar.gz: 1d65caa4489b6f54e2384bc939ddd9b8bd0fd50933fecf353f7e86d3eda1240891633769d6a229d14627fb8f387d590f0a9a3880a362371d48329fdce1338a20
@@ -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
- file_type = Fastlane::Helper::Ios::L10nHelper.strings_file_type(path: path)
60
- if file_type == :text
61
- duplicates = Fastlane::Helper::Ios::StringsFileValidationHelper.find_duplicated_keys(file: path)
62
- duplicate_keys[language] = duplicates.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless duplicates.empty?
63
- else
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 #{file_type} format, while finding duplicate keys only make sense on files that are in ASCII-plist format.
66
- Since your files are in #{file_type} format, you should probably disable the `check_duplicate_keys` option from this `#{action_name}` call.
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 = client.releases(repository).find { |candidate| candidate.tag_name == version }
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
- _, status = Open3.capture2('/usr/bin/plutil', '-lint', path)
29
- return nil unless status.success?
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
- string_keys = read_strings_file_as_hash(path: input_file).keys.map { |k| "#{prefix}#{k}" }
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
- # Read line-by-line to reduce memory footprint during content copy
97
- read_utf8_lines(input_file).each do |line|
98
- unless prefix.nil? || prefix.empty?
99
- # The `/u` modifier on the RegExps is to make them UTF-8
100
- line.gsub!(/^(\s*")/u, "\\1#{prefix}") # Lines starting with a quote are considered to be start of a key; add prefix right after the quote
101
- line.gsub!(/^(\s*)([A-Z0-9_]+)(\s*=\s*")/ui, "\\1\"#{prefix}\\2\"\\3") # Lines starting with an identifier followed by a '=' are considered to be an unquoted key (typical in InfoPlist.strings files for example)
102
- end
103
- tmp_file.write(line)
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, :after_quoted_key_and_equal,
11
- # :in_quoted_value, :after_quoted_value
12
- State = Struct.new(:context, :buffer, :in_escaped_ctx, :found_key)
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" => :root,
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
- '/' => :root,
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
@@ -3,6 +3,6 @@
3
3
  module Fastlane
4
4
  module Wpmreleasetoolkit
5
5
  NAME = 'fastlane-plugin-wpmreleasetoolkit'
6
- VERSION = '14.10.0'
6
+ VERSION = '14.11.0'
7
7
  end
8
8
  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.10.0
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: 2026-07-07 00:00:00.000000000 Z
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.235'
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.235'
81
+ version: '2.237'
83
82
  - !ruby/object:Gem::Dependency
84
83
  name: gettext
85
84
  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: []
@@ -477,6 +335,7 @@ files:
477
335
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_send_app_size_metrics.rb
478
336
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_metadata_source.rb
479
337
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_update_release_notes.rb
338
+ - lib/fastlane/plugin/wpmreleasetoolkit/actions/macos/macos_verify_code_signing.rb
480
339
  - lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb
481
340
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_emulator_helper.rb
482
341
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_localize_helper.rb
@@ -532,7 +391,6 @@ homepage: https://github.com/wordpress-mobile/release-toolkit
532
391
  licenses:
533
392
  - MIT
534
393
  metadata: {}
535
- post_install_message:
536
394
  rdoc_options: []
537
395
  require_paths:
538
396
  - lib
@@ -547,8 +405,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
547
405
  - !ruby/object:Gem::Version
548
406
  version: '0'
549
407
  requirements: []
550
- rubygems_version: 3.4.10
551
- signing_key:
408
+ rubygems_version: 3.6.9
552
409
  specification_version: 4
553
410
  summary: Fastlane plugin for release automation
554
411
  test_files: []