GitHub release에 바이너리 첨부 자동화

GitHub에서 release를 생성하면 source code의 snapshot이 zip과 tar.gz로 저장된다. 여기에 추가해서 컴파일된 결과가 자동으로 추가하도록 한다면, 간단히 source code와 연계된 바이너리도 함께 배포할 수 있을 것이다.

이 글에서는 안드로이드 프로젝트를 가정해서 release를 생성할 때 안드로이드 APK를 빌드하고 source code와 함께 배포하는 간단한 workflow를 설명한다.

전체 코드

Event trigger

on:
  release:
    types: [published]

Release에서만 동작하므로 event trigger는 release – published이다. 이 event는 web상에서 새로운 release package를 publish 할때 trigger 된다.

환경변수

env:
  TAG: ${{ github.ref_name }}
  ASSET_FILE_PATH: "./prebuilt-${{ github.ref_name }}.zip"

Release package를 생성할 때 넣는 version의 이름은 github.ref_name으로 참조 된다. 첨부되는 파일의 이름은 prebuilt-<version_tag>.zip으로 설정한다. 참고로 GitHub에서는 release에 추가되는 소스코드외의 파일들을 “Asset”이라 부른다.

빌드 수행 및 Asset 생성

    # Checkout source code and build Android APKs.
    - name: Checkout
      uses: actions/checkout@v3
    - name: Setup JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: gradle
    - name: Build Android APK and zip
      run: |
        sh ./gradlew assemble
        zip ${{ env.ASSET_FILE_PATH }} \
          ./app/build/outputs/apk/debug/app-debug.apk \
          ./app/build/outputs/apk/release/app-release*.apk

Android build를 위한 JAVA를 설정하고 Gradle의 assemble target을 설정하여 APK file에 대한 빌드를 수행한다. Assemble은 debug와 release용 두개의 apk를 성성하므로 이들을 하나의 zip파일로 만들어서 prebuilt-<version_tag>.zip에 추가한다.

Release에 Asset 추가

Release에 asset을 추가해주는 GitHub action은 보이질 않는다. 그래서 GitHub script를 이용해서 release에 asset을 추가하는 동작을 다음과 같이 정의했다. 이는 두 개의 동작으로 이루어지는데 하나는 주어진 tag로 release 정보를 찾아오는 것이고, 다른 하나는 가져온 release 정보에 파일을 업로드하는 것이다.

동작 1. Tag로 release 가져오기

GitHub script project의 README.md에 따르면 GitHub script상에서 github객체는 사전인증된(pre-authenticated) Octokit client라고 한다. 따라서 새롭게 instance를 만들지 않고 바로 Octokit의 API를 사용할 수 있다. getReleaseByTag()에 tag를 넘겨주면 통해 release에 대한 객체가 반환된다. 그 중에 release를 구분하기 위한 ID만 사용한다.

    // Get a release for given tag.
    const release = await github.rest.repos.getReleaseByTag({
       owner: context.repo.owner,
       repo: context.repo.repo,
       tag: process.env.TAG
    });
    const release_id = release.data.id;
    console.log("Release id for the tag " + process.env.TAG + ": " + release_id);

동작 2. Release에 파일 업로드 하기

앞의 과정에서 만든 업로드할 zip파일과 release를 구분할 ID 정보를 uploadReleaseAsset()에 넘겨 주면 해당 파일이 asset으로 등록된다.

    // Upload release assets.
    const fs = require("fs");
    const filename = process.env.ASSET_FILE_PATH.replace(/^.*[\\/]/, "");
    var uploaded = await github.rest.repos.uploadReleaseAsset({
       owner: context.repo.owner,
       repo: context.repo.repo,
       release_id: release_id,
       name: filename,
       data: await fs.readFileSync(process.env.ASSET_FILE_PATH),
    });
    console.log(
       process.env.ASSET_FILE_PATH
       + " has been uploaded as " + filename
       + " to the release " + process.env.TAG);

주의: upload 동작은 release를 수정하는 것이므로 workflow가 repository에 대한 write permission이 있어야 하므로 settings 항목에서 에서 write permission이 허가되어 있는지 확인한다. 이 부분이 안되어 있다면 403 error가 날 것이다.

Release workflow 실행

GitHub repo의 Code 탭에서 Tags -> Releases -> Tag -> Draft a new release를 선택한 후 Choose a tag를 눌러서 나오는 입력창에 새로 생성할 version tag를 입력해 Create new tag를 선택한 다음 Publish release 버튼을 누른다.

이 때 만들어지는 release에는 소스코드만 들어 있지만, 정상적으로 동작했다면 Actions tab에 release workflow가 등록되어 수행되는 것이 보일 것이다. Workflow가 정상 종료된 후 해당 release로 다시 가보면 asset이 등록되어 있는 것을 볼 수 있다.

Update failed: upgrade-temp-backup 디렉토리를 생성할 수 없습니다.

WordPress에서 몇 번 플러그인 업데이트 실패에 대한 알람이 왔었는데, 오늘에야 살펴보니 자동은 물론 수동 업데이트도 계속해서 실패하고 있었다.

새로 릴리즈된 WordPress 6.3에서 플러그인과 테마를 롤백할 수 있는 기능이 추가되었는데, 이 기능을 위해 wp-content/upgrade-temp-backup/plugins와 wp-content/upgrade-temp-backup/themes 디렉토리를 사용하는 모양이다. (관련 기사 링크)

문제의 원인은 간단했는데, wp-content 디렉토리의 쓰기 권한이 daemon에게 없기 때문에 디렉토리를 만들지 못하고 실패하는 것이었다. 따라서 간단히 디렉토리를 수동으로 만들고 권한을 할당해 주는 것으로 해결되었다.

콘솔에 접근해서 다음과 같이 명령어들을 입력한다.

# WordPress경로로 이동해서
#e.g) cd ~/apps/wordpress/htdocs

# 디렉토리 경로를 만들어주고
mkdir -p ./wp-content/upgrade-temp-backup/plugins
mkdir -p ./wp-content/upgrade-temp-backup/themes

# 소유권 그룹을 할당한 다음
sudo chown bitnami:daemon -R ./wp-content/upgrade-temp-backup

# daemon group에 쓰기 권한을 준다.
chmod 775 -R ./wp-content/upgrade-temp-backup

관리자 화면으로 돌아와서 설치 재시도 하니 잘 동작되었다.

Multi-container app의 Bad Gateway(502) 문제 디버깅

Intel Training and Learning Suite(TLS)는 처음으로 경험해보는 multi-container application이다. 중간에 native개발을 위한 환경 설정을 끼워 넣어 보려고 이것 저것 시도하는 중에 web interface를 담당하는 tls_proxy라는 docker가 Bad Gateway를 띄우면서 문제가 생겼다. 디버깅 하는 과정에서 사용해 본 docker-compose명령어 들의 쓰임새가 유용할 것 같아서 기록으로 남겨 둔다.

Port number와 docker찾기: docker-compose ps

Web browser로 서비스에 접근하면(https://localhost) Bad Gateway(502) error가 발생하는데 처음으로 web-browser의 접속 요청을 받아서 처리하는 docker container는 docker-compose ps 명령어로 docker image별로 listening하는 port number들을 찾을 수 있다.

TLS의 경우는 tls_proxy가 http요청 처리를 위한 80번과 https를 위한 443번 port를 listening하고 있다.

$ docker-compose ps
    Name                  Command               State     Ports                     
------------------------------------------------------------------
tls_apiui      ./webservices/start.sh           Exit 0                                                                                                                                                     
tls_core       ./start.sh                       Up                                                                                                                                                         
tls_mongo      docker-entrypoint.sh --tls ...   Up       127.0.0.1:27017->27017/tcp                                                                                                                        
tls_openvino   /bin/bash                        Exit 0                                                                                                                                                     
tls_proxy      /docker-entrypoint.sh /bin ...   Up       0.0.0.0:443->443/tcp,:::443->443/tcp, 80/tcp                                                                                                      
tls_rabbitmq   docker-entrypoint.sh /init ...   Up       0.0.0.0:15672->15672/tcp,:::15672->15672/tcp, 15691/tcp, 15692/tcp, 0.0.0.0:1883->1883/tcp,:::1883->1883/tcp, 25672/tcp, 4369/tcp, 5671/tcp, 5672/tcp, 0.0.0.0:8883->8883/tcp,:::8883->8883/tcp
tls_redis      docker-entrypoint.sh sh st ...   Up       127.0.0.1:6379->6379/tcp

Docker의 log 보기: docker logs tls_proxy

tls_proxy가 요청을 처리하지 못한 이유는 docker logs <서버 이름>으로 확인할 수 있다.

$ docker logs tls_proxy
2023/05/31 01:31:06 [error] 12#12: *5 connect() failed (113: Host is unreachable) while connecting to upstream, client: 172.31.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.31.0.8:3000/", host: "localhost"
...

사설 네트워크인 172.31.0.x로 docker service들을 구성했는데, 그중 인터페이스를 담당하는 172.31.0.1 docker가 172.31.0.8에 요청을 연결해 주어야 하는데 이 부분에서 오류가 나고 있는 모양이다.

Docker network 확인: docker network ls

현재 docker용으로 구성되어 있는 network을 확인해 보면 다음과 같다.

$ docker network ls
NETWORK ID     NAME                DRIVER    SCOPE
d15b28e7a1d5   bridge              bridge    local
acc99b5947d1   cvat_cvat           bridge    local
4485972f8274   host                host      local
6f21c18ba66f   none                null      local
20d132ccfc2a   tls_default         bridge    local

각 네트워크의 구성을 검사(inspect)해 볼 수 있는데 docker network inspect cvat_cvat명령어로 해당 네트워크를 검사해 보면 다음과 같이 위에서 오류가 발생했던 ip인 172.21.0.8번(tls_openvino)이 등록되지 않은 것을 볼 수 있다.

$ docker network inspect cvat_cvat|grep "Name\|IPv4Address"
        "Name": "cvat_cvat",
                "Name": "cvat_redis",
                "IPv4Address": "172.31.0.4/16",
                "Name": "cvat_opa",
                "IPv4Address": "172.31.0.3/16",
                "Name": "cvat_db",
                "IPv4Address": "172.31.0.5/16",
                "Name": "traefik",
                "IPv4Address": "172.31.0.2/16",
                "Name": "cvat",
                "IPv4Address": "172.31.0.6/16",
                "Name": "cvat_ui",
                "IPv4Address": "172.31.0.7/16",
                "Name": "tls_proxy",
                "IPv4Address": "172.31.0.9/16",

Docker의 log 보기: docker logs tls_openvino

문제가 되고 있는 docker container를 특정했으니 docker logs tls_openvino명령어로 다시 한번 해당 container의 로그를 확인해 본다.

error: A hook (`userconfig`) failed to load!
error: Failed to lift app: Error: Attempted to `require('/home/tls/webservices/apiserver/config/env/production.js')`, but an error occurred:
--
Error: ENOENT: no such file or directory, open '../../thirdparty/security/ca/ca_certificate.pem'
    at Object.openSync (fs.js:498:3)
    at Object.readFileSync (fs.js:394:35)
    at Object.<anonymous> (/home/tls/webservices/apiserver/config/env/production.js:70:15)

../../third party/security/ca/ca_certificate.pem파일을 읽지 못해서 오류가 발생했다고 나오는데, 실제로 container에서는 해당 파일의 위치를 상대 경로로 접근하지 않는다. Call stack에 나와 있는 production.js line 70근처의 내용을 보면 먼저 절대경로인 /run/secrets/*에 접근을 시도할 때 예외가 발생해서 상대 경로로 접근을 시도하고(개발용 코드로 추정) 이 마저도 실패한 것이 로그에 나온 것이다.

apiserver/config/env/production.js

try {
  tls_ca = fs.readFileSync("/run/secrets/ca_tls")
  tls_server_cert = fs.readFileSync("/run/secrets/tlsserver_cert")
  tls_server_key = fs.readFileSync("/run/secrets/tlsserver_key")
} catch (err) {
  tls_ca = fs.readFileSync("../../thirdparty/security/ca/ca_certificate.pem");
  tls_server_cert = fs.readFileSync("../../thirdparty/security/TLS_server_cert.crt");
  tls_server_key = fs.readFileSync("../../thirdparty/security/TLS_server_key.pem");
}

정리하자면, 문제는 server secret을 생성하는 과정에서 발생한 것이 docker image상의 key file path read에서 오류를 발생시키고 tls_openvino container가 제대로 뜨지 못한 문제이다.

해결(?)

문제의 원인은 생각보다 싱거웠는데, docker-compose 명령어를 실행시키는 native용 script에서 다음 줄을 연결하기 위한 \를 빼먹는 사소한(!) Bash 문법 오류가 발생 했었고, 이 오류가 무시된 채로 docker-compose build 명령어가 계속 수행되어 service가 up되는 상황까지 된 것이었다.

-       && pip install -U pip wheel setuptools
+       && pip install -U pip wheel setuptools \
        && pip install \

위의 수정과 함께 오류가 생기면 build를 멈추도록 set -e를 bash script에 추가 하는 것으로 이 문제는 일단락 되었다.

C++ template 선언과 구현을 서로 다른 파일에 나눌 수 없다

커스텀 Queue 클래스를 작성하는데 queue의 item은 추후에 변경 될 수 있으므로 템플릿으로 작성하고자 한다. Queue class를 header에 정의하고 구현을 cpp 파일에 작성해 준다음 main 함수에서 다음과 같이 queue를 생성하고 컴파일을 시도한다.

다음은 해당 template을 사용하는 caller(main 함수)이다.

//
// C++ template instantiation test.
//
//                                             - litcoder

#include <iostream>
#include "queue.h"

using namespace std;
int main() {
    // Create an integer type queue.
    queue<int> q;

    // Push elements from the queue.
    q.push(0);
    q.push(1);
    q.push(2);

    // Pop and print all elements.
    while (! q.empty()) {
        cout << "Popped: " << q.pop() << endl;
    }

    return 0;
}

하지만 별 문제 없을것이라는 생각과 달리 안타깝게도 빌드는 실패한다. 공들여서 만든 method가 링커에 의해 하나도 빠짐없이 “undefined reference” 오류를 뱉으면서 실패한다.

$ make
g++  -g -Wall  -c -o queue.o queue.cpp
g++  -g -Wall  -c -o main.o main.cpp
g++ -o qtest queue.o main.o
/usr/bin/ld: main.o: in function `main':
/home/litcoder/Downloads/cpptemplate/main.cpp:12: undefined reference to `queue<int>::queue()'
/usr/bin/ld: /home/litcoder/cpptemplate/main.cpp:15: undefined reference to `queue<int>::push(int)'
/usr/bin/ld: /home/litcoder/cpptemplate/main.cpp:16: undefined reference to `queue<int>::push(int)'
/usr/bin/ld: /home/litcoder/cpptemplate/main.cpp:17: undefined reference to `queue<int>::push(int)'
/usr/bin/ld: /home/litcoder/cpptemplate/main.cpp:21: undefined reference to `queue<int>::pop()'
/usr/bin/ld: /home/litcoder/cpptemplate/main.cpp:20: undefined reference to `queue<int>::empty()'
/usr/bin/ld: /home/litcoder/cpptemplate/main.cpp:25: undefined reference to `queue<int>::~queue()'
/usr/bin/ld: /home/litcoder/cpptemplate/main.cpp:25: undefined reference to `queue<int>::~queue()'
collect2: error: ld returned 1 exit status
make: *** [Makefile:7: qtest] Error 1

이 문제에 대한 해결책은 생각보다 간단한데, cpp파일에 따로 분리했던 구현 부분을 선언들과 함께 header file에 두는 것이다. (해치웠나?)

$ make
g++  -g -Wall  -c -o queue.o queue.cpp
g++  -g -Wall  -c -o main.o main.cpp
g++ -o qtest queue.o main.o
$ ./qtest
Popped: 0
Popped: 1
Popped: 2

왜 undefined reference 오류가 났을까?

Queue.o가 빌드됐으니 거기 보면 분명히 저 method들이 선언되어 있을텐데 말이다. 빌드된 symbol들을 살펴보자,

$ objdump -t ./queue.o

./queue.o:     file format elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*	0000000000000000 queue.cpp

O.O 없다. 아무것도. queue.o는 아무 symbol이 없는 빈 파일이었다.

앞서 해결됐다고 했던 header에서 template 선언과 구현을 모두 수행하는 경우에도 queue.o의 symbol table이 비어 있는것은 마찬가지 이지만, 이 경우, caller인 main.o에는 template이 실제 사용하는 type에 binding되어 embed되어 있다. main.o의 symbol들을 살펴보면 다음과 같이 mangle된 symbol들을 볼 수 있다.

$ objdump -t ./main.o

./main.o:     file format elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*	0000000000000000 main.cpp
...
0000000000000000  w    F .text._ZN5queueIiEC2Ev	0000000000000048 _ZN5queueIiEC1Ev
0000000000000000  w    F .text._ZN5queueIiE4pushEi	000000000000003f _ZN5queueIiE4pushEi
...

참고로, C++ symbol의 type은 demangler를 이용하면 볼 수 있는데, demangle한 결과는 다음과 같다.

_ZN5queueIiEC2Ev -> queue<int>::queue()
_ZN5queueIiE4pushEi -> queue<int>::push(int)

결론

C++ template은 빌드하는 시점에서 어떤 data type으로 binding될 지 알 수 없기 때문에 구현 부분을 별도의 파일로 만들어 봤자 아무 것도 빌드 되지 않는다. Header file에 선언과 구현을 함께 두고 이 template을 사용하는 code가 이를 include하여 어떤 data type으로 binding될 지 결정해 주면 그때서야 실제 symbol이 caller module에 포함된다.

즉, template을 선언과 구현으로 나누어서 따로 빌드하는 것은 불가능할 뿐더러 의미도 없으니 include될 하나의 파일(주로 header)에 넣어서 선언과 구현을 합쳐두는게 가장 좋다.

Macbook pro 나비 키보드 불량에 따른 보상

메일이 하나 날아들었는데, 내 오래된 맥북 pro의 키보드 불량 때문에 보상을 해주겠다는 내용이었다.

메일에 따르면 보상은 세개의 그룹으로 나누어, 두 번 이상의 키보드 교체를 받고도 문제가 재발한 경우(Group 1), 한번 키보드를 교체 했는데 문제가 재발한 경우(Group 2) 그리고 하나 이상의 키캡 교체를 받고도 문제가 재발한 경우(Group 3) 각각에 대하여 최대 $300-$395, $125, $50을 보상한다고 한다. 아마도 총 5천만 USD에서 제반 비용을 빼고, 나머지를 claim하는 사람 만큼으로 나누어 주기 때문에 최대 한도만 적어 놓은 듯 하다.

다행히 예전에 키보드를 교체한 이력에 대한 영수증을 버리지 않고 있어서 claim해보기로 했다. 나비 키보드는 상판(Top case)와 함께 교체 하기에 수리 명세서에는 “Korean, Top Case”라고 적혀 있다.

Claim page로 들어가면 email로 전달되는 Unique IDPIN을 입력하게 되는데, 이게 Macbook pro의 model 번호와 연결되어 있어서 모델 번호가 잘 못 입력된 경우는 이를 수정 할 수 없다. 만약 모델 번호가 잘 못 입력되어 있거나 case에 해당 함에도 메일을 받지 못했을 경우라면 정보를 직접 입력하는 claim page에서 시작하면 모델번호를 직접 입력할 수 있다.

2023년 3월 6일까지이니 케이스에 해당된다면 서두르시라.