왜 gaji인가? - TS로 안전하게 GitHub Actions 작성하기

최근에 저는 TS로 GitHub Actions를 작성하기 위한 툴을 만들었습니다. 그 이름하여 GitHub Actions Justified Improvements, gaji 라는 툴입니다. 저는 왜 TS로 GitHub Actions를 작성하게 되었으며, 기존 툴들과 어떤 점이 다를까요? 같이 알아보시죠. 가지 공식 문서 Toss Client DevOps Team에서의 인턴 근무 올해 1월부터, 저는 Toss Client DevOps Team 에서 인턴 근무를 시작했습니다. Client DevOps Team을 가장 단순하게 표현하자면, 클라이언트 개발자가 빠르고 안전하게 배포할 수 있는 인프라 환경을 구축하는 팀입니다.제가 주로 진행한 작업은 기존 워크플로우를 GitHub Actions로 전환하고, 새로운 검사를 위한 커스텀 액션을 만드는 일이었습니다.수십 개의 워크플로우를 다루면서, 빠르고 안전한 배포 인프라를 만들어야 하는 팀에서 정작 그 인프라를 만드는 과정 자체는 느리고 불안전하다는 걸 체감했습니다. 오타 하나를 확인하려면 커밋 → 푸시 → CI 실행 → 실패 확인이라는 사이클을 반복해야 했고, 로컬에서 재현할 방법이 없으니 git 실력만 늘었습니다. 인턴을 하며 자리잡은 생각들 이런 인턴 근무를 하면서, 몇가지 생각이 자리잡았습니다. 철학까지는 아니고, 단순한 생각 정도입니다. 입출력이 명확해야 좋은 소프트웨어입니다. YAML은 동작을 표현할 언어가 아닙니다. Actions는 입출력과 사이드이펙트가 있는 동작입니다. 이걸 데이터를 표현하기 위한 언어인 YAML로 표현하는 것 자체가 언어의 사용처가 잘못된 것이 아닐까요? 선언적이지 않은 걸 선언적으로 표현하려다 보니, YAML 안에 셸 스크립트를 넣는 기형적인 구조가 되어버렸습니다. 어느 환경에서든 재현 가능해야 좋은 도구입니다. gaji는 이 중 1, 2번에서 출발했고, 3번은 act 같은 도구의 영역입니다. GitHub Actions의 3가지 구조적 문제 위 생각을 가지고 GitHub Actions를 보면, 다음과 같은 문제점이 있습니다. YAML은 데이터 표현 언어지, 동작을 표현하기에 적합하지 않습니다. 타입 검사가 없습니다. 외부 저장소에 의존할 일이 많은데(actions/checkout@v5조차 외부 저장소입니다), 이들이 요구하는 입력에 대한 검증이 전혀 없습니다. 사용자가 직접 문서를 보고 일일이 형식에 맞게 입력해야 합니다. 로컬에서 재현하기가 힘듭니다. 이 세 가지가 결합해 GitHub Actions는 실행하기 전까지 간단한 오타 하나도 못 찾는 플랫폼이 되었습니다. name: CIon: push: branches: [main]jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-versoin: '20' # 키 이름 오타! 런타임까지 오류 없음 ❌ cache: 'npm' - run: npm ci - run: npm test gaji는 첫 번째와 두 번째 문제를 해결하는 데 집중합니다. 기존 도구들과의 비교 actionlint 솔직히 말하면, gaji를 만들 당시에는 actionlint의 존재를 몰랐습니다. 이후에 알게 되었는데, 훌륭한 도구입니다. ${{ }} 표현식의 타입 체크, 액션 입력 검증, shellcheck 통합 등 YAML 워크플로우의 오류를 상당히 잘 잡아줍니다. 다만 근본적인 접근 방식이 다릅니다. actionlint는 YAML을 유지하면서 사후에 오류를 잡는 린터이고, gaji는 YAML 자체를 벗어나서 작성 시점에 오류를 불가능하게 만드는 접근입니다. 린터는 "실수를 알려주고", 타입 시스템은 "실수를 하기 어렵게 만듭니다." 개발 경험 측면에서도, actionlint는 별도 CLI를 실행하거나 에디터 플러그인을 설치해야 하지만, gaji는 TypeScript 네이티브 자동완성과 인라인 타입 힌트가 에디터에서 즉시 동작합니다. 이 둘을 같이 쓰면 더욱 좋습니다. gaji가 생성한 YAML을 actionlint로 검증하면 가장 이상적인 조합이 됩니다. gaji가 TypeScript 단에서 액션 입력의 타입을 잡고, actionlint가 ${{ }} 표현식 검증 같은 YAML 단의 검사를 보완합니다. emmanuelnk/github-actions-workflow-ts github-actions-workflow-ts는 TS로 GitHub Actions를 표기한다는 아이디어의 출발점이 된 프로젝트입니다. action.yml에서 타입을 자동 생성한다는 아이디어 자체는 gaji와 동일합니다. 다만 코드젠의 주체가 다릅니다. github-actions-workflow-ts는 메인테이너가 trackedActions 목록을 관리하여 npm 패키지로 배포하는 방식이고, gaji는 사용자가 참조하는 모든 액션에 대해 즉시 로컬에서 타입을 생성합니다. github-actions-workflow-ts의 장점은 명확합니다. npm 패키지를 설치하면 바로 사용 가능하고, 사용자가 별도의 코드젠을 실행할 필요가 없습니다. step outputs에 대한 타입 안전성도 지원합니다. 반면 단점도 있습니다. 메인테이너가 관리하는 목록에 있는 액션만 타입이 지원되므로, 커스텀 액션이나 GHE 내부 액션은 사용할 수 없습니다. 새 액션이나 버전 추가도 메인테이너에 의존하고, 외부 JS 런타임이 필요합니다. gaji의 장점은 사용자가 참조하는 어떤 액션이든 즉시 타입을 생성한다는 점입니다. 커스텀 액션이든 GHE 내부 액션이든 상관없습니다. Rust 바이너리로 동작하므로 JS 런타임도 불필요합니다. 반면 단점으로는 사용자가 gaji dev를 실행해야 타입이 생성되고, 타입이 로컬에서 생성되므로 프로젝트마다 세팅이 필요하다는 점이 있습니다. 저는 GHE 환경에서 수많은 커스텀 액션을 다뤘던 경험 때문에, gaji 쪽의 접근이 필요하다고 판단했습니다. gaji의 접근법 왜 이렇게 만들었는가 왜 Rust인가? 빠르기 때문입니다. 단순히 빌드된 바이너리의 속도를 이야기하는 것이 아닙니다. clippy, rustfmt 등 여러 검사 도구가 내장되어 있어서 LLM을 이용한 개발 속도를 크게 줄여주었습니다. 덕분에 인턴을 하면서도 빠르게 만들 수 있었습니다. 또한 oxc 등 Rust로 작성된 TypeScript 지원 도구들이 이미 성숙해 있어서, Rust에서 TypeScript를 다루는 것 또한 편했습니다. 왜 TypeScript인가? 우선 제가 JS/TS 개발자입니다. TypeScript의 타입 시스템은 강력하면서도 보편적이라 많은 개발자가 이미 익숙합니다. 그리고 GitHub Actions의 YAML 구조가 본질적으로 JSON과 유사하므로, TS/JS에서 JSON-like 객체로 표현하기가 매우 자연스럽습니다. 이를 단적으로 보여주는 것은 모든 gaji 워크플로우 파일이 그 자체로 유효한 TS 파일이라는 점입니다. Deno처럼 TS를 바로 실행할 수 있는 환경에서 gaji로 작성된 워크플로우를 실행하면, 해당 워크플로우를 JSON으로 표현한 결과를 출력합니다. 왜 action.yml 자동 코드젠인가? Client DevOps Team에서 커스텀 액션을 작성하는 일을 했고, 이미 상당히 많은 커스텀 액션이 존재했습니다. 이들의 문서를 일일이 보며 작성하는 것이 매우 힘들었던 경험이 직접적인 동기였습니다. Hackers.pub에 기여하면서 GraphQL 자동 코드젠의 개념을 접했고, 같은 접근을 GitHub Actions에 적용할 수 있겠다고 판단했습니다. 핵심 구조 gaji 워크플로우는 getAction() → Job → Workflow → .build()의 흐름으로 구성됩니다. gaji dev --watch를 실행하면 새 액션 참조를 감지하여 자동으로 타입을 생성합니다. import { getAction, Job, Workflow } from "../generated/index.js";const checkout = getAction("actions/checkout@v5");const setupNode = getAction("actions/setup-node@v4");const build = new Job("ubuntu-latest") .addStep(checkout({})) .addStep(setupNode({ with: { "node-version": "20", cache: "npm", }, })) .addStep({ run: "npm ci" }) .addStep({ run: "npm test" });const workflow = new Workflow({ name: "CI", on: { push: { branches: ["main"] } },}).addJob("build", build);workflow.build("ci"); 이렇게 작성하면 모든 액션 입력에 대한 자동완성, 컴파일 시점 타입 체크, action.yml 문서의 IDE 힌트 표시, 기본값 표시가 모두 동작합니다. CompositeJob으로 공통 로직을 클래스로 추출하거나, CallJob으로 재사용 가능한 워크플로우를 호출하는 것도 TS 코드상에선 자연스럽습니다. 실제 사례: gaji 자체의 릴리즈 CD gaji는 모든 ci/cd를 gaji로 작성하고 있습니다. 그중에서 제일 복잡한 release.ts는 4개의 Job으로 구성되어 있습니다. build: 5개 플랫폼(linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64) 크로스 빌드 upload-release-assets: GitHub Release에 바이너리와 체크섬 업로드 publish-npm: npm에 플랫폼별 패키지 퍼블리시 publish-crates: crates.io에 OIDC 기반 퍼블리시 이 워크플로우가 YAML로 컴파일되면 약 150줄의 평탄한 구조가 됩니다. 주석 없이는 Job 간 경계나 의존관계를 파악하기 어렵습니다. TypeScript 버전에서는 build, uploadReleaseAssets, publishNpm, publishCrates라는 변수명만으로 구조가 즉시 파악됩니다. 6개의 외부 액션을 타입 안전하게 사용하고, 복잡한 매트릭스 빌드와 OS별 분기가 코드 구조 안에서 가독성 있게 표현됩니다. gaji의 한계 gaji에는 여전히 한계가 존재합니다. 근본적으로, 최종 산출물이 여전히 YAML입니다. GitHub Actions 플랫폼의 입력 형식이 YAML인 이상, gaji도 그 제약 안에 갇힙니다. gaji는 이 플랫폼 위에서의 최선이지, 이상적인 해답은 아닙니다. 원본 action.yml의 inputs가 문자열이나 숫자 정도로만 표현되기 때문에, "npm" | "yarn" | "pnpm" 같은 세밀한 값 수준의 타입까지는 제공할 수 없습니다. ${{ matrix.target.rust_target }} 같은 GitHub Actions 표현식도 여전히 순수 문자열이라 타입 검증이 불가능합니다. 기술적인 제한도 있습니다. gaji dev는 getAction()을 정적 분석해서 실행되기 때문에 문자열 리터럴만 지원하고, 변수나 템플릿 리터럴은 사용할 수 없습니다. 문자열 값 자체의 오타(cache: "npn" vs cache: "npm")도 잡을 수 없고요. 앞으로의 방향 gaji의 현재 아키텍처는 TypeScript → Parse (oxc) → Execute (QuickJS) → YAML입니다. 코드젠을 만들면서 한 가지 아이디어를 얻었는데, 프론트엔드(사용자가 작성하는 언어)와 백엔드(YAML 생성)를 잘 분리하고, 중간 언어를 잘 정의하면 TypeScript 외의 다른 언어로도 워크플로우를 작성할 수 있겠다는 것입니다. 1.0에서는 플러그인 시스템을 도입해 다른 언어 지원을 확장할 수 있는 구조를 만들 계획입니다. gaji의 핵심 가치인 action.yml 자동 타입 생성을 TypeScript에 국한하지 않고 확장하고 싶습니다. Special Thanks gaji 브랜드 이름 제안을 해 주신 kiwiyou 님, RanolP 님, 로고 제작을 해 주신 sij411 님께 감사드립니다.Client DevOps Team에게도 감사합니다. 이 팀에서 겪은 경험이 아니었으면 YAML과 GitHub Actions에 대해 생각해 보지 않았을 겁니다.emmanuelnk/github-actions-workflow-ts에게도 감사를 표합니다. TS로 GitHub Actions를 표기한다는 아이디어와 기본적인 TS API 설계는 여기서 가져왔습니다.

Hackers' Pub

Why Your GitHub Actions Secrets Don’t Work in Reusable Workflow Inputs

We recently migrated our Docker build workflows to use a shared reusable workflow. The migration looked straightforward: extract the build steps, parameterize the inputs, and call the shared workflow with `secrets: inherit`. CI immediately broke.

Invalid workflow file (Line: 15, Col: 19): Unrecognized named-value: 'secrets'

The fix took 20 minutes. Understanding why took longer, and every answer led to another “but wait” question.

The Setup

The calling workflow passed a Rails master key as a build arg:

jobs: build: uses: our-org/shared/.github/workflows/build-image.yml@main secrets: inherit with: build_args: | RAILS_MASTER_KEY=${{ secrets.RAILS_MASTER_KEY }}

This fails at parse time. GitHub validates the workflow file before anything runs and rejects `secrets` in that `with:` block.

Job-Level `with:` Is Not Step-Level `with:`

This is where it gets confusing, because `with:` appears in two very different places in a workflow file, and they have different rules.

Step-level `with:` passes inputs to an action. The `secrets` context is available here:

steps: - uses: docker/build-push-action@v6 with: build-args: RAILS_MASTER_KEY=${{ secrets.RAILS_MASTER_KEY }} # works

Job-level `with:` passes inputs to a reusable workflow. The `secrets` context is not available here:

jobs: build: uses: org/repo/.github/workflows/shared.yml@main with: build_args: ${{ secrets.RAILS_MASTER_KEY }} # fails

The contexts reference documents this in a table. For `jobs..with.`, the allowed contexts are:

> `github, needs, strategy, matrix, inputs, vars`

No `secrets`.

But the Docs Say Secrets Are Available “From Any Step in a Job”

The secrets context documentation says:

> This context is the same for each job in a workflow run. You can access this context from any step in a job.

That’s true, for steps. The job-level `with:` on a reusable workflow call is not a step. It’s a job-level declaration that gets parsed and validated with a restricted set of contexts before any job runs.

But I Have `secrets: inherit`

The reusable workflows docs explain that `secrets: inherit` implicitly passes secrets to the called workflow. And it does. At runtime, the called workflow’s steps can reference `secrets.RAILS_MASTER_KEY` directly.

The problem is that `with:` is evaluated on the caller side. `secrets: inherit` makes secrets available inside the called workflow, not in the caller’s `with:` expression. These are two parallel channels:

  • `with:` sends named inputs (restricted contexts, validated at parse time)
  • `secrets:` sends secrets (available at runtime in the called workflow’s steps)

But What About Org vs Repo Secrets?

Doesn’t matter for this error. `secrets: inherit` passes both org-level and repo-level secrets to the called workflow. The error is a static validation failure at parse time. GitHub isn’t even looking at which secrets exist or where they’re stored. It’s rejecting the `secrets` context in `with:` regardless.

The Fix

Pass the secret name as a string through `with:`. Resolve it inside the called workflow where `secrets` is available.

In the calling workflow:

with: master_key_secret: RAILS_MASTER_KEY # plain string, no secrets context

In the shared workflow, add an input and resolve it in the build step:

on: workflow_call: inputs: master_key_secret: description: "Secret name for Rails master key (empty to skip)" required: false type: string default: ""

In the build step:

build-args: | ${{ inputs.master_key_secret != ” && format(‘RAILS_MASTER_KEY={0}’, secrets[inputs.master_key_secret]) || ” }}

The `secrets[inputs.master_key_secret]` pattern dynamically looks up a secret by name. It’s the same approach GitHub’s own docs use for parameterized registry credentials. The input defaults to empty, so repos that don’t need it aren’t affected.

The Takeaway

Two things that look the same in YAML, `with:` on a step and `with:` on a reusable workflow call, have fundamentally different context availability. If you’re migrating from inline steps to reusable workflows and your `secrets.*` references break, that’s why. Pass the name, not the value.

References:

#ciCd #githubActions #reusableWorkflows

When working in a #continuousintegration system, here's a bit of advice: It's generally preferable for the output of the testing suite to be full of green checkmarks, with no red x's.

✅ = Good
❌= Bad

#programming #webdev #github #githubactions #development #cicd #devops

*proceeds to flip table again*

🎙️ Automated my podcast publishing workflow! 🚀

New episodes from my RSS feed automatically become formatted posts on my website using GitHub Actions + R + Quarto.

No more manual work. No more copy-paste. Just automatic publishing.

Built with: R, GitHub Actions, Quarto, magick 📦

Pods: https://federicagazzelloni.com/content/podcasts/
Code: https://github.com/Fgazzelloni/fgazzelloni.github.io

#Automation #RStats #DataScience #GitHubActions

Actioneer now supports workflow_dispatch inputs when triggering workflows manually—dynamic fields, less guesswork.
https://flathub.org/en/apps/me.spaceinbox.actioneer

#GitHubActions #CICD #Linux #Flathub #OpenSource #DevTools

A single tag = A new release?

In general, Git tags are like bookmarks of a specific commit in any branch that will give you a quick pointer to a specific point in the Git history. Those tags specify a point or a milestone in th…

Aptivi
GitHub Actions Is Slowly Killing Your Engineering Team - Ian Duncan

Why GitHub Actions is the Internet Explorer of CI, and why Buildkite offers a better path forward for teams that care about developer experience.

Ian Duncan

Actioneer update is live on Flathub: https://flathub.org/en/apps/me.spaceinbox.actioneer

What’s new:

- Job logs render ANSI colors
Workflow sections are grouped (less scrolling, easier to follow)
- Aligned timestamps + clearer command/output formatting
- Stronger error highlighting
- Packaging CI refreshed (multi-arch AppImage/Snap/Flatpak) + dependency updates

#Linux #Flathub #OpenSource #DevTools #CICD #GitHubActions #GTK #FOSS

I've been using Eleventy to build this blog for getting close to two years. It works great. I have some idiosyncracies in how I transfer the blog that is more complex than just an SCP so I created a yarn task for it. The template I use already used yarn so I just continued with that. I created a task called "yarn transfer" that does an scp to move the newly rebuilt blog but also preserve some files outside that process that would otherwise get blown away every time. All good.

The one thing that bedeviled me was the Github Actions. Ideally I would be able to build the site from Github actions on every commit and also set this up on a schedule. It's how I have done other static site generated blogs and it works great. However getting the SSH keys set up always failed for me. I tried a number of things and they all failed. Today I felt like giving it another shot.

This time I tried using the Install SSH Key action prior to the yarn transfer. Getting that key set up never worked right, even though I have my private key in secrets and everything seemed like it should. I set it up and I'll be damned if it didn't work the first try.

Once that worked, I did one more commit to set up a daily schedule. This will allow me to schedule posts ahead of time, namely my podcast episodes, and have Github build them and make them live without my realtime interaction.

This post is being written exactly that way. If you see it as the top post on this site, then the scheduled build worked!

#GithubActions #Eleventy #11ty

Eleventy is a simpler static site generator

Eleventy is a simpler static site generator.

Eleventy