Keeping the export compliance answer in sync across two files and ASC

Jaemyeong Jin···7 min read
한국어

I ship DailySudoku, a personal app that gives one Sudoku game a day, on iOS, Android, and the web together. The release channels are the App Store, Google Play, and the web. Pushing a git tag runs everything automatically up to the review request in App Store Connect (ASC).

While making the review submission safe to rerun, I added a check for the encryption declaration. In the process I fixed defects of my own making four more times. The code described here is the store metadata check and the iOS release automation on the develop branch as of August 16, 2026.

Where the answer lives

Uploading an app requires declaring whether it uses encryption. In the repository, that value is in two places. One is the ITSAppUsesNonExemptEncryption key in Info.plist. The other is the submission declaration file that the automation takes as input.

If the two values disagree, ASC asks an extra question and the pipeline stops. The declaration file had the value false and a comment saying that both files must be changed together. No code actually checked what that comment said.

Having an answer is not the same as having the right one

At first I decided not to compare the values. If the version is waiting for a compliance answer, the flow moves to manual handling anyway, so I judged that a separate comparison was not needed.

That judgment had a gap. Whether an answer exists and whether that answer equals the approved value are two different conditions. After a person enters a value in ASC, or after an earlier automated run enters a different value, the state still becomes resolved. If the build, the release notes, and the release method also match, a rerun can be judged a success. Then the review goes ahead with a declaration outside what was approved. A wrong declaration can become a reason for a review rejection or a policy violation.

As the first fix, the actual value in ASC is compared with the approved value, and a missing answer blocks the run.

A regression found 30 minutes later

That fix caused another problem right away. The function that supplies the approved value uses Ruby symbols as keys, but the code that reads the value looked it up with a string key.

This is the result of looking up the same value with the two kinds of key.

문자열로 조회: nil     ← 옛 코드
심볼로 조회  : false   ← 고친 코드 (= 선언 파일의 승인값)

The first line is the lookup with a string, which was the old code. The second is the lookup with a symbol, which is the fixed code and equals the approved value in the declaration file.

Because the lookup returned nil and not false, the value was always judged different from the one in ASC. As a result, every retry was blocked.

This was the third defect that blocked everything all the time. The earlier two were in the promotion comparison and the locale identifiers, and this one was the lookup of the declared value. The existing self-test checked only the number of keys and the types of the values. So I added an assertion that looks the value up with exactly the key the consuming code uses.

Looking once more for the same kind of defect

Before review, I searched for more blocking paths of the same kind, and a fourth candidate came up. The previous commit treated a nil value in ASC as a missing answer.

But when compliance is settled by the declaration in the app binary’s Info.plist, the declaration field of the Build can be empty. This is an inference from how the submission tool is implemented. The submission tool, fastlane deliver, writes the value only when it is nil. Shipped as it was, the code would have blocked valid retries too. This time it was found before release.

After the change, the approved value is compared only when the ASC field has a value. A case that is really unresolved is stopped by the check on whether the version is waiting for an answer. The roles are separated so that a nil value and an unresolved state are not treated as the same thing.

An offline check that compares the two files

Apart from the runtime decision, I turned the contract inside the repository into a check. It compares Info.plist with the submission declaration. A conflict is known as soon as only one file is edited, so it can be caught on every PR.

This is the part that asserts the two values read from the files are equal.

plist_value = plist[key]                    # Info.plist
declared = decl[field]                      # 제출 선언 파일
assert plist_value == declared, (
    "수출 규정 선언이 두 곳에서 어긋난다 — ASC가 되묻거나 잘못된 신고로 심사가 진행된다. "
    "**한쪽만 고치지 말 것**(선언 파일 주석)."
)

The second comment means “submission declaration file.” The message says that the export compliance declaration disagrees in two places, that ASC will ask again or the review will go ahead with a wrong declaration, and that one side must not be fixed alone.

The type is checked as well. When a value is extracted with plutil, a false inside an XML string node also comes out as a string. Converting only the result of comparing that string with “true” into a Boolean hides the fact that the original type was wrong. So the file is parsed with Python’s plistlib, and then the value is checked to be a bool.

This assertion confirms that the value read from Info.plist is a Boolean.

assert isinstance(plist_value, bool), (
    f"Info.plist 의 {key} 가 Boolean 이 아니다: {plist_value!r}. "
    "ASC 는 이 키를 Boolean 으로 요구한다 — <true/> 또는 <false/> 여야 한다"
)

The message says that the key in Info.plist is not a Boolean, and that ASC requires this key as a Boolean, written as <true/> or <false/>.

A missing key is a failure too, because without the key ASC asks again and the pipeline stops.

The check also ran in the wrong place

I first put this check in the self-test of the iOS release workflow. On a PR, that workflow runs only when a path under apps/ios/** changes. The submission declaration file is outside that path, so a PR that changed only the declaration did not run the check.

I moved it into the store metadata check, which owns both files. That check runs on every PR and push.

I confirmed the result in three ways. A mutation that flips the Boolean in Info.plist makes the assertion fail. The self-test looks the value up with the key that the consuming code really uses. And I matched every gem accessor and constant used in the code against the pinned source, one by one, because referring to names without checking them was the repeated cause. Feeding a check an input that should fail is covered in the post on applying a negative control to a verification script.

A promise written as a comment is easy to mistake for a managed contract, so it should become an assertion. Searching for more failure paths of the same kind while fixing one defect paid off. And the change a check watches has to be included in the conditions that trigger that check. Adding a safeguard can itself create new errors, which is what happened here.

I have never made a real submission with mismatched declarations. Testing the review queue with a wrong declaration was not an option. The statement that ASC asks again when the two values conflict or the key is missing rests on the experience recorded in the declaration file’s comment and on the API’s requirements. The basis for the nil handling is also the tool’s implementation, and I could not confirm in the documentation the exact condition under which ASC fills the Build’s declaration field.

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.