Rust 크레이트 crates.io 배포하기
회사에서 Rust로 만든 오픈소스 애플리케이션을 하나 관리하고 있는데요. 라이브러리가 아니라 실행 파일이라서 그동안 바이너리와 Docker 이미지로만 배포해 왔어요. 그런데 어느 날 이런 이슈가 올라왔습니다.
많은 회사가 규정상 crates.io 미러에 있는 패키지만 받아서 소스로 빌드해야 합니다. git 소스로만 공개된 패키지는 아예 쓸 수가 없어요.
생각해 보니 맞는 말이었습니다. 보안 규정이 엄격한 조직일수록 외부 바이너리를 그대로 들이지 않고 사내 레지스트리(registry)에 미러링한 크레이트(crate)만 빌드에 쓰게 하거든요. 그래서 crates.io에 처음으로 크레이트를 올려 보기로 했는데 생각보다 여러 번 넘어졌습니다. 😅
이번 글에서는 그 과정을 순서대로 정리해 보겠습니다. 처음 배포하기 전에 Cargo.toml에서 무엇을 챙겨야 하는지, 토큰을 발급해 손으로 배포하는 방법, 그리고 토큰 없이 GitHub Actions에서 자동으로 배포하는 Trusted Publishing 설정까지 살펴볼게요. 예제와 실행 결과에서는 실제 프로젝트 대신 acme-cli라는 예시 이름을 쓰겠습니다.
배포 전에 Cargo.toml 점검하기
crates.io는 패키지 레지스트리라서 한번 올린 내용을 고칠 수 없습니다. 그래서 첫 배포 전에 메타데이터부터 꼼꼼히 채워 두는 게 좋아요.
[package]
name = "acme-cli"
version = "2.0.0"
edition = "2024"
description = "A command-line tool for managing Acme resources"
license = "MIT"
repository = "https://github.com/acme/acme-cli"
readme = "README.md"
keywords = ["cli", "acme", "devtools", "productivity", "terminal"]
categories = ["command-line-utilities"]
우선 description과 라이선스는 사실상 필수입니다. 둘 중 하나라도 없으면 crates.io가 업로드를 거부해요. keywords는 최대 5개까지만 넣을 수 있고 categories는 아무 문자열이나 쓰는 게 아니라 crates.io에 정해진 카테고리 슬러그 중에서 골라야 합니다. readme에는 크레이트 페이지에 보여 줄 마크다운 파일 경로를 적습니다.
라이선스는 저도 놓친 부분인데요. 제 프로젝트는 license-file = "LICENSE"로 파일만 지정하고 있었는데 이렇게 올리면 crates.io가 라이선스 종류를 알지 못해 “non-standard”로 표시합니다. 회사에서 cargo-deny 같은 도구로 의존성 라이선스를 검사한다면 이런 크레이트는 “알 수 없는 라이선스”로 걸리기 쉬워요. MIT나 Apache-2.0처럼 표준 라이선스라면 license = "MIT"처럼 SPDX 식별자로 적어 주세요.
다만 워크스페이스(workspace)라면 한 가지를 더 챙겨야 합니다. LICENSE 파일이 워크스페이스 루트에 있으면 각 크레이트 폴더 밖이라서 license만 적었을 때 패키지에 들어가지 않거든요. MIT 라이선스는 사본에 저작권 고지를 함께 넣도록 요구하니 파일이 빠지면 안 됩니다. 이럴 때는 두 필드를 함께 적으면 라이선스 종류도 알리고 파일도 패키지에 넣을 수 있어요.
[workspace.package]
license = "MIT"
license-file = "LICENSE"
어떤 파일이 패키지에 들어가는지는 cargo package --list로 미리 확인할 수 있습니다.
cargo package --list -p acme-cli-core
다음으로 이름이 비어 있는지 확인합니다. crates.io의 크레이트 이름은 선착순이라 이미 누가 쓰고 있으면 다른 이름을 골라야 해요. API를 호출해 보면 바로 알 수 있는데, crates.io API는 User-Agent 헤더가 없는 요청을 403으로 거절하니 -A 옵션으로 누가 호출하는지 밝혀 줍니다.
curl -s -A "my-name-check (me@example.com)" https://crates.io/api/v1/crates/acme-cli
{"errors":[{"detail":"crate `acme-cli` does not exist"}]}
마지막으로 --dry-run 옵션으로 실제 업로드 직전까지 전 과정을 리허설해 봅니다. 패키지를 묶고 묶인 소스만으로 빌드가 되는지 검증까지 해 주니 첫 배포 전에는 꼭 돌려 보세요.
cargo publish --dry-run
워크스페이스라면 path 의존성에 버전 붙이기
처음 넘어진 곳은 Cargo 워크스페이스였습니다. 제 저장소는 실행 파일 크레이트 하나와 내부 라이브러리 크레이트 세 개로 이루어져 있고 실행 파일 쪽에서 나머지를 path로 참조하고 있었어요.
[dependencies]
acme-cli-core = { path = "../acme-cli-core" }
acme-cli-config = { path = "../acme-cli-config" }
acme-cli-storage = { path = "../acme-cli-storage" }
이 상태로 --dry-run을 돌리면 바로 실패합니다.
cargo publish --workspace --dry-run
error: failed to verify manifest at `.../crates/acme-cli/Cargo.toml`
Caused by:
all dependencies must have a version requirement specified when publishing.
dependency `acme-cli-config` does not specify a version
Note: The published dependency will use the version from crates.io,
the `path` specification will be removed from the dependency declaration.
오류 메시지가 친절하게 이유까지 알려 주는데요. crates.io에 올라간 크레이트는 로컬 경로를 알 수 없으니 배포할 때 path를 지우고 version만 남깁니다. 그러니 version이 반드시 있어야 하죠. 로컬에서는 path를, crates.io에서는 version을 쓰도록 둘 다 적어 주면 됩니다. 워크스페이스 루트의 [workspace.dependencies]에 모아 두면 버전을 한 곳에서 관리할 수 있어요.
[workspace.dependencies]
acme-cli-core = { path = "crates/acme-cli-core", version = "2.0.0" }
acme-cli-config = { path = "crates/acme-cli-config", version = "2.0.0" }
acme-cli-storage = { path = "crates/acme-cli-storage", version = "2.0.0" }
[dependencies]
acme-cli-core.workspace = true
acme-cli-config.workspace = true
acme-cli-storage.workspace = true
여기서 한 가지 더 짚을 점이 있습니다. crates.io는 의존성도 전부 crates.io에 있어야 받아 줘요. 내부용으로만 쓰려던 라이브러리 크레이트 세 개도 결국 함께 공개해야 한다는 뜻입니다. 공개 API로 쓰이길 원하지 않는다면 description에 “내부용이며 안정적인 API가 아니다”라고 밝혀 두는 정도가 현실적인 선택이에요. 이걸 피하려면 라이브러리를 실행 파일 크레이트의 모듈로 합쳐야 하는데 코드가 수천 줄이라면 배보다 배꼽이 더 커집니다.
크레이트가 여러 개일 때는 의존 순서대로 하나씩 올려야 해서 번거로웠는데 Cargo 1.90부터 --workspace 옵션이 생겨서 의존 관계를 따져 알아서 순서대로 올려 줍니다.
API 토큰 발급하고 로그인하기
이제 crates.io에 로그인할 차례입니다. crates.io는 GitHub 계정으로 로그인하고, Cargo에서 쓸 API 토큰은 Account Settings의 API Tokens에서 New Token을 눌러 만듭니다.

토큰에는 스코프(scope)를 골라 권한을 좁힐 수 있는데요. 화면에 보이는 다섯 가지는 다음과 같습니다.
change-owners: 크레이트 소유자(owner)를 초대하거나 제거합니다.publish-new: 새 크레이트를 처음 배포합니다.publish-update: 이미 있는 크레이트에 새 버전을 배포합니다.trusted-publishing: Trusted Publishing 설정을 관리합니다.yank: 배포한 버전을 철회(yank)하거나 되살립니다.
저는 처음에 publish-new와 publish-update만 골랐다가 배포 뒤 팀을 소유자로 추가하는 단계에서 막혔습니다. 첫 배포에 쓸 토큰이라면 change-owners까지 함께 고르는 게 좋아요. 아래 Crates 항목에서 크레이트 이름 패턴을 지정하면 그 크레이트에만 쓸 수 있게 범위를 더 좁힐 수도 있습니다. 첫 배포만 하고 버릴 토큰이니 만료 기간도 짧게 잡았어요.
토큰을 만들었으면 터미널에서 cargo login을 실행하고 프롬프트에 토큰을 붙여 넣습니다.
cargo login
토큰은 ~/.cargo/credentials.toml에 저장됩니다. 셸 히스토리에 토큰이 남지 않도록 cargo login <토큰>처럼 인자로 넘기지 않는 편이 안전해요.
첫 배포에서 만난 이메일 인증
준비가 다 됐다고 생각하고 실제로 배포를 실행했습니다.
cargo publish --workspace
그런데 첫 크레이트를 올리자마자 거절당했어요.
Uploading acme-cli-config v2.0.0 (...)
error: failed to publish acme-cli-config v2.0.0 to registry at https://crates.io
note: the following crates have not been published yet:
acme-cli v2.0.0 (...)
acme-cli-core v2.0.0 (...)
acme-cli-storage v2.0.0 (...)
Caused by:
the remote server responded with an error (status 400 Bad Request): A verified email address is required to publish crates to crates.io. Visit https://crates.io/settings/profile to set and verify your email address.
crates.io는 인증된 이메일이 있는 계정만 배포를 허용합니다. GitHub로 로그인하더라도 이메일이 자동으로 인증되지는 않으니 Account Settings의 Profile에서 이메일을 등록하고 받은 메일의 링크를 눌러 인증해야 해요. 다행히 첫 크레이트에서 거절돼서 아무것도 올라가지 않은 상태였고 인증을 마친 뒤 같은 명령을 다시 실행하니 네 개가 순서대로 올라갔습니다.
Uploaded acme-cli-config v2.0.0 to registry `crates-io`
Uploaded acme-cli-core v2.0.0 to registry `crates-io`
Uploaded acme-cli-storage v2.0.0 to registry `crates-io`
note: waiting for acme-cli-config v2.0.0, acme-cli-core v2.0.0, or acme-cli-storage v2.0.0 to be available at registry `crates-io`.
Published acme-cli-config v2.0.0, acme-cli-core v2.0.0, and acme-cli-storage v2.0.0 at registry `crates-io`
Uploaded acme-cli v2.0.0 to registry `crates-io`
note: waiting for acme-cli v2.0.0 to be available at registry `crates-io`
Published acme-cli v2.0.0 at registry `crates-io`
의존성 세 개가 레지스트리 인덱스에 반영될 때까지 기다렸다가 실행 파일 크레이트를 올리는 모습이 보이죠?
배포는 되돌릴 수 없다는 점은 꼭 기억해 두세요. 문제가 있는 버전은 철회해서 새 프로젝트가 그 버전을 고르지 않게 할 수는 있지만 같은 버전 번호로 내용을 바꿔 다시 올릴 수는 없습니다. 그래서 저는 main 브랜치가 아니라 이미 출시된 v2.0.0 태그를 체크아웃한 뒤 위의 Cargo.toml 수정만 얹어서 배포했어요. crates.io의 2.0.0이 이미 배포된 2.0.0 바이너리와 똑같은 코드여야 하니까요.
이전 메이저 버전도 올릴 수 있을까?
2.0.0을 올리고 나니 아직 1.x를 쓰는 사용자가 마음에 걸렸습니다. crates.io는 버전 순서를 강제하지 않아서 2.0.0을 올린 뒤에도 1.x 버전을 얼마든지 올릴 수 있어요. 마지막 1.x 릴리스인 v1.20.0 태그를 체크아웃해서 같은 방식으로 배포했습니다.

버전 목록을 보면 crates.io가 메이저 버전별로 릴리스 트랙을 나눠서 보여 줍니다. 1.20.0을 나중에 올렸어도 크레이트의 기본 버전은 여전히 가장 높은 2.0.0이라 그냥 cargo install acme-cli를 실행하면 2.0.0이 설치돼요. 1.x가 필요한 사용자는 버전을 명시하면 됩니다.
cargo install acme-cli --version 1.20.0
화면의 “NON-STANDARD” 표시는 앞에서 말한 license-file 때문입니다. 올리기 전에 알았다면 좋았을 텐데 말이죠.
소유권을 팀으로 넘기기
크레이트를 처음 올린 사람이 유일한 소유자가 됩니다. 회사 프로젝트를 개인 계정 하나에만 묶어 두면 그 사람이 팀을 떠날 때 곤란해지니 GitHub 팀을 소유자로 추가해 두는 게 좋아요. 팀 소유자는 github:<조직>:<팀> 형식으로 지정합니다.
cargo owner --add github:acme:maintainers acme-cli
앞에서 말한 대로 저는 토큰에 change-owners 스코프가 없어서 여기서 막혔습니다.
error: failed to invite owners to crate `acme-cli` on registry at https://crates.io
Caused by:
the remote server responded with an error (status 403 Forbidden): this token does not have the required permissions to perform this action
토큰을 새로 만들기 번거롭다면 웹에서 처리해도 됩니다. 크레이트 페이지의 Settings 탭에 있는 Owners 섹션에서 Add Owner를 누르고 같은 형식으로 팀을 입력하면 돼요.

팀 소유자와 개인 소유자는 권한이 조금 다릅니다. 팀에 속한 사람은 새 버전을 배포하거나 철회할 수 있지만 소유자를 추가하거나 제거하지는 못해요. 소유자 관리는 개인 소유자만 할 수 있으니 개인 소유자를 너무 일찍 지우지 않도록 주의하세요. 그리고 crates.io가 조직의 팀 정보를 읽으려면 조직에서 crates.io 앱의 접근을 허용해 둬야 합니다. 팀을 추가할 때 오류가 난다면 조직 설정부터 확인해 보세요.
Trusted Publishing으로 토큰 없이 배포하기
손으로 배포하는 건 이 정도로 충분하지만 릴리스할 때마다 누군가 토큰을 들고 배포하는 건 좋은 방법이 아닙니다. 그렇다고 오래 유효한 토큰을 GitHub Actions 시크릿에 넣어 두는 것도 마음이 편하지 않죠. 시크릿이 새면 누구든 크레이트를 올릴 수 있으니까요.
Trusted Publishing은 이 문제를 OpenID Connect(OIDC)로 풉니다. 원리는 GitHub Actions에서 OIDC로 클라우드에 인증하는 방법과 같아요. 워크플로우가 GitHub에서 “나는 어느 저장소의 어느 워크플로우다”라고 서명된 ID 토큰을 받아 crates.io에 내밀면, crates.io는 미리 등록해 둔 저장소, 워크플로우, 환경(environment)과 일치하는지 확인한 뒤 짧은 시간만 유효한 배포용 토큰을 내줍니다.
flowchart TB
accTitle: Trusted Publishing으로 배포 토큰을 받는 흐름
accDescr: GitHub Actions 작업이 GitHub에서 OIDC ID 토큰을 받아 crates.io에 제출하면, crates.io가 등록된 저장소와 워크플로우와 환경이 일치하는지 확인한 뒤 짧게 유효한 토큰을 발급하고, 작업은 그 토큰으로 cargo publish를 실행한다.
job["GitHub Actions 작업<br/>(id-token: write)"] -->|"ID 토큰 요청"| gh["GitHub OIDC 공급자"]
gh -->|"서명된 ID 토큰"| job
job -->|"ID 토큰 제출"| crates{"crates.io<br/>저장소, 워크플로우,<br/>환경이 일치하는가?"}
crates -- "예" --> token(["짧게 유효한<br/>배포 토큰 발급"])
crates -- "아니요" --> deny(["거절"])
token --> publish["cargo publish"]
저장소 어디에도 비밀이 저장되지 않는다는 게 가장 큰 장점입니다. 다만 Trusted Publishing은 이미 존재하는 크레이트의 설정 화면에서 등록하기 때문에 첫 배포만큼은 앞에서처럼 토큰으로 직접 해야 해요. 제가 손으로 먼저 배포한 이유가 바로 이것입니다.
GitHub 환경 만들기
먼저 배포 작업이 쓸 GitHub Actions 환경을 만듭니다. 저장소의 Settings에서 Environments로 이동해 New environment를 누르고 이름을 crates-io로 지었어요.

Trusted Publishing에 환경이 꼭 필요하지는 않지만 쓰기를 강력히 권합니다. crates.io 쪽에 환경 이름까지 등록해 두면, 같은 저장소에 쓰기 권한이 있는 사람이라도 그 환경을 거치지 않는 워크플로우로는 토큰을 받을 수 없거든요. 환경 설정 화면에서는 Required reviewers로 승인자를 지정하거나 Deployment branches and tags에서 배포할 수 있는 브랜치나 태그를 제한하는 보호 규칙도 걸 수 있습니다.

화면 아래쪽을 보면 Environment secrets가 비어 있죠? Trusted Publishing을 쓰면 이 환경에는 시크릿을 하나도 넣을 필요가 없습니다.
crates.io에 신뢰할 워크플로우 등록하기
이제 crates.io에 “이 워크플로우가 보낸 요청은 믿어도 된다”고 알려 줄 차례입니다. 크레이트의 Settings 탭에서 Trusted Publishing 섹션의 Add를 누르면 등록 폼이 나와요.

Publisher는 GitHub를 고르고 저장소 소유자와 저장소 이름, 워크플로우 파일명, 그리고 방금 만든 환경 이름을 입력합니다. 워크플로우 파일명은 경로 없이 .github/workflows/ 아래의 파일 이름만 적어요. 입력하면 crates.io가 저장소에 그 파일이 실제로 있는지 확인해서 “Workflow file found”라고 알려 주니 오타를 바로잡을 수 있습니다.
등록을 마치면 Trusted Publishing 섹션에 방금 입력한 내용이 나타납니다.

이 설정은 크레이트마다 따로 해야 해서 워크스페이스의 크레이트 네 개에 모두 같은 내용을 등록했습니다. 아래의 “Require trusted publishing for all new versions” 옵션을 켜면 API 토큰으로 배포하는 길을 아예 막을 수 있어요. CI에서 첫 자동 배포가 성공하는 걸 확인한 뒤 켜는 편이 안전합니다.
배포 워크플로우 작성하기
마지막으로 crates.io에 등록한 이름 그대로 .github/workflows/release.yml 워크플로우를 만듭니다. v로 시작하는 태그를 푸시하면 네 개의 크레이트를 순서대로 배포하는 예제예요.
name: Release
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: ubuntu-latest
# crates.io에 등록한 환경 이름과 같아야 함
environment: crates-io
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Authenticate with crates.io (Trusted Publishing)
uses: rust-lang/crates-io-auth-action@v1
id: auth
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
# 의존성부터 순서대로 올리고, 이미 올라간 버전은 건너뜀
for crate in acme-cli-core acme-cli-config acme-cli-storage acme-cli; do
if curl -sf -A "acme-cli-release (github.com/acme/acme-cli)" \
"https://crates.io/api/v1/crates/${crate}/${version}" > /dev/null; then
echo "${crate}@${version} is already on crates.io, skipping"
else
cargo publish --locked -p "${crate}"
fi
done
핵심은 세 군데입니다. 우선 permissions에 id-token: write가 있어야 작업이 GitHub에서 OIDC 토큰을 받을 수 있어요. 또 environment는 crates.io에 등록한 환경 이름과 정확히 같아야 합니다. 그리고 rust-lang/crates-io-auth-action이 ID 토큰을 crates.io의 배포 토큰으로 바꿔 출력해 주면, 그 값을 CARGO_REGISTRY_TOKEN 환경 변수로 넘겨 cargo publish가 쓰게 합니다. 이 토큰은 짧은 시간만 유효하고 작업이 끝나면 액션이 알아서 폐기해요.
배포 단계에서 cargo publish --workspace 대신 반복문을 쓴 데는 이유가 있습니다. 워크스페이스 배포는 원자적(atomic)이지 않아서 네 개 중 두 개만 올라가고 작업이 실패할 수 있어요. 그러면 다시 실행했을 때 이미 올라간 버전을 또 올리려다 실패하거든요. 그래서 crates.io API로 그 버전이 있는지 먼저 확인하고 없을 때만 올리도록 했습니다. 이렇게 해 두면 실패한 작업을 그냥 다시 실행하면 됩니다.
태그를 푸시할 때만 워크플로우가 돌게 한 것도 눈여겨보세요. 기능 브랜치에서 실수로 배포되는 일을 막을 수 있습니다. 한 단계 더 조이고 싶다면 crates-io 환경의 Deployment branches and tags에서 v* 태그만 배포할 수 있도록 제한해 두세요. 누군가 워크플로우를 고쳐 다른 브랜치에서 돌리더라도 환경 단계에서 막힙니다.
마치며
crates.io에 크레이트를 처음 올리면서 겪은 과정을 정리해 봤습니다. Cargo.toml의 메타데이터와 워크스페이스 의존성 버전을 챙기고, 스코프를 좁힌 토큰으로 첫 배포를 한 뒤, 소유권을 팀에 넘기고, 이후 배포는 Trusted Publishing으로 GitHub Actions에 맡기는 흐름이었어요. 돌아보면 넘어진 곳은 대부분 오류 메시지가 정확히 알려 주는 것이었는데 한번 올리면 되돌릴 수 없다는 점 때문에 매번 한 번 더 확인하게 되더라고요.
이번 글에서는 버전 번호를 올리고 체인지로그를 만드는 일은 다루지 않았습니다. 이런 작업까지 자동화하고 싶다면 release-plz로 릴리스를 자동화하는 방법을 참고해 보세요. Trusted Publishing은 release-plz와도 함께 쓸 수 있습니다. 공개 API가 있는 라이브러리 크레이트라면 배포 전에 cargo semver-checks로 호환성을 검사해 두는 것도 추천드려요.
Trusted Publishing의 자세한 설정 방법은 crates.io 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0