How to Easily Distribute to 97 Countries in 32 Languages

We'll be doing 'localization' this time. But without the 'hassle'...
DelightRoom's avatar
Dec 12, 2024
How to Easily Distribute to 97 Countries in 32 Languages

1

We'll be doing 'localization' this time. But without the 'hassle'...

Hello, I'm Leedi, an iOS engineer for Alarmy.

How is your localization productivity doing?
Today, I’m going to talk about DelightRoom's localization process, which distributes to 97 countries in over 30 languages.

First, here is a brief overview of our current localization pipeline.

I'll be explaining this again later anyway, so you don't need to understand it completely; just getting a feel for it is enough.

  1. The DesignGroup first uploads the necessary localization strings to Lokalise.

  2. Lokalise creates a PR in the alarmy-ios-localise repo (submodule).

  3. We generate a Localizations.swift file holding the keys as static properties via the LaurineGenerator script.

  4. A python script performs a validCheck, and if it passes, it automatically merges.
    (Success/failure is sent as a slack message)

  5. The submodule dispatches a submodule update request to the alarmy-ios repo.

  6. The develop branch of the alarmy-ios repo updates the submodule to the latest version.

Among these, the green ones [1,2] are the design team's work, and the blue ones [3,4,5,6] are automated actions using github action.

In other words, there is currently zero effort made for localization in DelightRoom's iOS group.

Especially since step 4 was recently implemented, we even saved the cost of reviewing PRs coming up in the group. And today, I want to talk about this story.

Here is an explanation of each strategy.
(The numbers below are the order of work and are unrelated to the pipeline order above.)

1 . Localizations.swift

Back in April 2022, when I first joined DelightRoom, the DelightRoom iOS group was also doing localization work in a highly inefficient way.
(Having to copy and paste everything by hand...)

The only saving grace was that we had some level of automation where building in Xcode after copy-pasting would run a script called LaurineGenerator to create a Localizations.swift file containing key values as static properties. And we were using it with a slight modification to fit the team's needs.

Running the script generates the following file.

LaurineGenerator created that file based on the keys in Base.lproj, and unsurprisingly, we were still making quite a few mistakes.

ex) Entering a key value different from Base, or omitting it entirely..

Because of the inefficiencies arising here, in May, a month after I joined, iOS also adopted Lokalise, a localization support tool that was already being used by the Android group.

2. Lokalise

So, what is Lokalise?

Lokalise is a cloud-based platform that helps efficiently manage the localization process for various digital products like software, websites, mobile apps, and games. Localization involves tasks related to multilingual support, playing a crucial role in translating and localizing content so that users can use the product globally.

That was a very neat summary provided by GPT.
In short, it's a tool that makes supporting multiple languages for a product effortless.

To give you a quick example,

You fill in the Key and the values for each language like this. (Empty fields are automatically filled with the Base language.)

And when you click the Build button like this?!

A PR with the applied changes is generated like this.

How about that?
Super easy, right?

Of course, there were many incidents during the Lokalise integration process, too,
but that's not really important, so I'll wrap up the explanation here.

3. Submodule

However, problems arose here too.
Looking back, I had clearly defined the problem situation at the time as follows.

That is, it was great that Lokalise automatically created PRs, but having files generated externally where we couldn't manage them led to constant merge conflicts, and it was also causing inefficiencies in our github strategy.

So, I proposed using a submodule approach as a strategy to solve this.

What is a submodule?

A Git Submodule is a feature that allows you to include another Git repository within a Git repository. This lets you include and manage independent external projects or libraries within your main project. -by GPT

Simply put, a submodule is an independent repository, and the main repository can pin and use a specific version via a commit hash.

In our case, if we made Localization a submodule, the develop branch of the main repository would always point to the latest commit address, and each branch (hotfix or feature branch) could flexibly change it as needed.

The resulting screen

Now, we were finally able to easily hop between localization versions however we wanted. By moving the address wherever we needed at any time, we could easily apply the desired version of localization without needing to Rebase or cherrypick anymore.

But it would be a shame to stop here, right?

4. github action

As I mentioned in step 1, we were managing files through LaurineGenerator. And since localization management was moved out into a submodule, managing it from Xcode felt weird.

A teammate updates the submodule, builds Xcode, and merges it back?

Just imagining it is inefficient.
Therefore, we decided to get friendly with github action, a good buddy that could solve this.

What is github action?

GitHub Actions is a CI/CD(Continuous Integration/Continuous Deployment) tool provided by GitHub. With it, you can automatically build, test, and deploy code, or automate various workflows.

It's easiest to understand it as a buddy inside github that performs an action based on a specific trigger.

The first goal was very simple.

  • When a PR starting with lokalise is opened, execute localization.sh. Then commit the changes.

on:
  pull_request_target:
    types:
      - opened

jobs:
  localization:
    if: startsWith(github.head_ref, 'lokalise')
    runs-on: macos-latest
    steps:
      - name: Check out lokalise branch
        uses: actions/checkout@v3
        with:
          ref: ${{ github.head_ref }}
      
      - name: Pull Request
        run: |
          git pull origin
      
      - name: Run Script
        id: script
        run: |
          ls -al
          chmod +x ./Scripts/localization.sh
          ./Scripts/localization.sh
      
      - name: commit and push
        uses: stefanzweifel/git-auto-commit-action@v4
        with:
          commit_message: "[Success] complete localization build"

It looked clean.
Now, after a teammate merges into the submodule, the develop branch in the main repo just needs to update the submodule to the latest.

But it's not actually clean, is it?
That's right.

A teammate has to merge AND update develop to the latest.. that's too much of a hassle, isn't it?

5. dispatch submodule

So, one more goal was added.

  • When a PR is merged, I want the main repository to automatically update to the latest!

I didn't know much about github action either, so I wasn't sure if this was technically possible (though I figured it definitely would be), but when I looked it up, it was already easily doable.

As expected, senior engineers who always pave the way for us
permissions: write-all
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Workflow
        uses: actions/github-script@v6
        with:
          github-token: ${{ secrets.BUILDER_BOT_1 }}
          script: |
            github.rest.actions.createWorkflowDispatch({
              owner: 'delightroom',
              repo: 'alarmy-ios',
              workflow_id: 'update_submodule.yml',
              ref: 'develop',
            })

If the submodule throws it like this

on:
  workflow_dispatch:
permissions: write-all
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: true
          token: ${{ secrets.BUILDER_BOT_1 }}
            
      - name: Pull & update submodules recursively
        run: |
          git submodule update --init --recursive
          git submodule update --recursive --remote

      - name: Commit & push changes
        run: |
          git config --global user.name 'GitHub Actions'
          git config --global user.email 'builder-bot-1@delightroom.com'
          git add .
          git commit -am "Update submodules"
          git push

The main-repo catches it perfectly like this and updates the submodule to the latest.

So now, even the annoying submodule update is handled!
We have defeated the enemies of localization!

Or have we..?

6. Review.. Review..

We weren't the only ones whose productivity improved, so the speed of applying localization got faster and faster, and the number of PRs Lokalise generated gradually increased.
Just like how Coupang Rocket Delivery order volumes exploded once free shipping was introduced.

10 a day..?!

Initially, it wasn't a problem.
We just had to check the very last one and merge it anyway.
Closing the previous PRs while we were at it.

However, the PRs piling up every time we visited the submodule repo messed with our minds, and since there were only a few things the engineering team actually checked (honestly, as long as the Keys are fine, it's all good..), we decided to automate this part too.

Moreover, unlike when we did the initial setup, we had the reliable Claude and GPT by our side, so there was absolutely no difficulty in making this decision.

My favorite dev.kyungsoo's instatoon

Putting all our trust in our AI friends, the automation train set off and yielded this cute little script that runs a python script (validate_variables.py) once localization is done.

  review-and-merge:
    needs: localization
    if: ${{ needs.localization.result == 'success' }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout PR branch
        uses: actions/checkout@v3
        with:
          ref: ${{ github.head_ref }}

      - name: Checkout base branch
        uses: actions/checkout@v3
        with:
          path: base_branch
          ref: ${{ github.base_ref }}

      - name: Get First Commit Message
        id: commit
        uses: actions/github-script@v6
        with:
          script: |
            const { data: commits } = await github.rest.pulls.listCommits({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.payload.pull_request.number
            });
            const firstCommit = commits[0];
            core.setOutput('message', firstCommit.commit.message);

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.x'

      - name: Install GitPython
        run: pip install gitpython

      - name: Run validation script
        id: validate
        run: |
          python Scripts/validate_variables.py
        env:
          GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        continue-on-error: true

      - name: Notify Slack on Validation Failure
        if: steps.validate.outcome == 'failure'
        uses: slackapi/slack-github-action@v2.0.0
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: webhook-trigger
          payload: |
            message: "❌ PR Validation Failed\n\nPR: ${{ github.event.pull_request.html_url }}\nBranch: `${{ github.head_ref }}`\nCommit: ${{ steps.commit.outputs.message }}\nError: ${{ env.VALIDATION_ERROR }}"

      - name: Close PR on Validation Failure
        if: steps.validate.outcome == 'failure'
        uses: actions/github-script@v6
        with:
          script: |
            await github.rest.pulls.update({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.payload.pull_request.number,
              state: 'closed'
            });
            
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.payload.pull_request.number,
              body: `❌ PR Validation Failed\nError: ${process.env.VALIDATION_ERROR}`
            });

      - name: Merge PR
        if: steps.validate.outputs.can_merge == 'true'
        id: merge
        uses: actions/github-script@v6
        with:
          script: |
            await github.rest.pulls.merge({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: ${{ github.event.pull_request.number }},
              merge_method: 'squash'
            });
        continue-on-error: true

      - name: Notify Slack on Merge Success
        if: steps.merge.outcome == 'success'
        uses: slackapi/slack-github-action@v2.0.0
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: webhook-trigger
          payload: |
            message: "🎉 PR Merge Success\n\nPR: ${{ github.event.pull_request.html_url }}\nBranch: `${{ github.head_ref }}`\nCommit: ${{ steps.commit.outputs.message }}

I cobbled this together with my GPT friend and honestly didn't struggle much.. so I don't really have any anecdotes for this part.

And while I was at it, I wanted to receive the job results in slack, so I ended up adding this buddy as well.
(Checking it manually is annoying.. It's not just me, right..?)

And the result?!

You can see it working beautifully like this :)

Now, there is absolutely nothing left for engineers to do for localization.

If the designer puts up a localization PR,
- it will automatically review it, notify us if there are errors, and if everything is normal, it will automatically merge it and finish updating the submodule to the latest version.

Just in case you've been having difficulties with localization work, I've compiled this post about our work history and details in the hopes that you might get some good ideas. I hope this was helpful, and if you can't be bothered to apply it yourself, you are more than welcome to join DelightRoom haha

Thank you for reading to the end.

Share article