Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 103 additions & 6 deletions .github/workflows/sbom-generation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,29 @@ on:
types: [published]

concurrency:
group: sbom-generation-${{ github.repository }}-${{ github.event.release.tag_name || github.ref }}
cancel-in-progress: true
group: >-
sbom-generation-${{ github.repository }}-${{
github.event_name == 'release' &&
format('release-{0}', github.event.release.id) ||
format('run-{0}', github.run_id) }}
# GitHub's native queue serializes release writes by immutable release ID.
# The queue is bounded at 100; later arrivals are rejected once it is full.
# https://docs.github.com/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency
queue: max

permissions:
contents: read

jobs:
generate-sbom:
runs-on: ubuntu-latest
concurrency:
group: >-
sbom-generation-job-${{ github.repository }}-${{
github.event_name == 'push' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'push' }}
permissions:
# write is needed for release-asset upload and dependency submission.
# Write is needed for release-asset upload and dependency submission.
contents: write
steps:
- name: Checkout
Expand All @@ -61,9 +73,10 @@ jobs:
output-file: sbom.spdx.json
artifact-name: sbom-spdx-json
upload-artifact: true
upload-release-assets: true
upload-release-assets: false
# Feeds the repo dependency graph -> read back by the org aggregator.
dependency-snapshot: true
# Release runs do not race protected-branch snapshots for this correlator.
dependency-snapshot: ${{ github.event_name == 'push' }}

- name: Generate CycloneDX SBOM
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
Expand All @@ -73,5 +86,89 @@ jobs:
output-file: sbom.cyclonedx.json
artifact-name: sbom-cyclonedx-json
upload-artifact: true
upload-release-assets: true
upload-release-assets: false
dependency-snapshot: false

- name: Verify current release revision
if: github.event_name == 'release'
# A release may remain mutable. This checks its live release ID, tag,
# and peeled tag commit immediately before publishing, but the GitHub
# API does not make this check and the following upload atomic.
# https://docs.github.com/rest/releases/releases#update-a-release
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_RELEASE_ID: ${{ github.event.release.id }}
EXPECTED_RELEASE_TAG: ${{ github.event.release.tag_name }}
EXPECTED_RELEASE_SHA: ${{ github.sha }}
run: |
set -euo pipefail

fail_release_revision() {
echo "::error::Current release revision could not be verified."
exit 1
}

[[ "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] \
|| fail_release_revision
[[ "$EXPECTED_RELEASE_ID" =~ ^[0-9]+$ ]] || fail_release_revision
[[ "$EXPECTED_RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] || fail_release_revision
git check-ref-format "refs/tags/${EXPECTED_RELEASE_TAG}" >/dev/null \
2>&1 || fail_release_revision

if ! release_json="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/${EXPECTED_RELEASE_ID}" \
2>/dev/null
)"; then
fail_release_revision
fi
actual_release_id="$(jq -er '.id | select(type == "number") | tostring' <<<"$release_json")" \
|| fail_release_revision
actual_release_tag="$(jq -er '.tag_name | select(type == "string" and length > 0)' <<<"$release_json")" \
|| fail_release_revision
release_immutable="$(jq -er '.immutable | select(type == "boolean") | tostring' <<<"$release_json")" \
|| fail_release_revision
[[ "$actual_release_id" == "$EXPECTED_RELEASE_ID" ]] || fail_release_revision
[[ "$actual_release_tag" == "$EXPECTED_RELEASE_TAG" ]] || fail_release_revision

encoded_tag="$(jq -rn --arg tag "$EXPECTED_RELEASE_TAG" '$tag | @uri')" \
|| fail_release_revision
# The commits endpoint resolves both lightweight and annotated tags
# to their commit without duplicating Git tag-peeling logic here.
# https://docs.github.com/rest/commits/commits#get-a-commit
if ! tag_commit_json="$(
gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_tag}" \
2>/dev/null
)"; then
fail_release_revision
fi
tag_commit_sha="$(jq -er '.sha | select(type == "string" and test("^[0-9a-f]{40}$"))' <<<"$tag_commit_json")" \
|| fail_release_revision
[[ "$tag_commit_sha" == "$EXPECTED_RELEASE_SHA" ]] \
|| fail_release_revision
printf 'RELEASE_REVISION_VERIFIED release_id=%s sha=%s immutable=%s\n' \
"$EXPECTED_RELEASE_ID" "$EXPECTED_RELEASE_SHA" "$release_immutable"

- name: Publish verified release SBOM assets
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail

for sbom_file in sbom.spdx.json sbom.cyclonedx.json; do
test ! -L "$sbom_file"
test -f "$sbom_file"
test -s "$sbom_file"
done
jq -e 'type == "object" and (.spdxVersion | type == "string" and startswith("SPDX-"))' \
sbom.spdx.json >/dev/null
jq -e 'type == "object" and .bomFormat == "CycloneDX"' \
sbom.cyclonedx.json >/dev/null

gh release upload \
--clobber \
--repo "$GITHUB_REPOSITORY" \
-- "$EXPECTED_RELEASE_TAG" \
"sbom.spdx.json" \
"sbom.cyclonedx.json"
68 changes: 68 additions & 0 deletions docs/doctoring/sbom-release-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# SBOM 릴리스 직렬화 계약

`sbom-generation.yml`은 보호 브랜치 push의 dependency snapshot과
`release: published`의 SPDX·CycloneDX asset 게시를 한 job에서 수행한다.
두 이벤트의 부작용과 취소 정책이 달라 concurrency도 두 단계로 나눈다.

## 확인한 기존 구현

- 저장소에는 release ID, tag, commit을 함께 검증하는 helper가 없었다.
`repository-metadata-reconcile.yml`과 `sast-semgrep.yml`의 기존 검사는
checkout 결과와 기대 SHA만 비교하므로 release의 현재 tag를 확인하지 않는다.
- pinned `anchore/sbom-action@e22c3899`는 release payload의 release ID를
대상으로 같은 이름의 asset을 삭제한 뒤 다시 올린다. 같은 release를
병렬 실행하면 이 두 호출이 서로 엇갈릴 수 있다.
- pinned `publish-sbom`은 먼저 현재 workflow의 artifact를 찾지만, 이름에
맞는 artifact가 없으면 release의 `target_commitish`에서 최신 workflow
run을 찾아 그 run의 artifact로 fallback한다. 하나만 맞아도 그 하나를
게시하고, 하나도 없으면 warning만 남기고 성공 반환한다. 이 동작은 현재
run의 두 산출물이 모두 존재한다는 계약과 맞지 않아 사용하지 않는다.

## 선택한 경계

- workflow-level `release.id` 그룹과 `queue: max`가 같은 release의 실행을
직렬화한다. GitHub의 native queue 상한은 100이며 이를 넘은 실행은
보존되지 않는다.
- push workflow admission은 run별로 분리하고, 기존 job-level repository/ref
그룹에서만 `cancel-in-progress: true`를 적용한다.
- release 생성 단계는 현재 checkout에 두 SBOM 파일을 만들고 dependency
snapshot을 제출하지 않는다. 게시 직전 live release ID·tag와 tag가
가리키는 commit을 다시 확인한다. 이어 두 로컬 파일이 비어 있지 않은
일반 파일인지와 SPDX·CycloneDX의 최소 JSON 표식을 확인한 뒤 기존
`gh release upload --clobber`로 두 파일을 한 호출에서 게시한다. 다른 run의
artifact를 조회하지 않으며 `actions: read` 권한도 추가하지 않는다.
- tag는 `git check-ref-format refs/tags/...`로 Git ref 문법과 제어문자를
먼저 거른다. 업로드 명령은 옵션 뒤 `--`를 두어 `-`로 시작하는 tag도
positional 인자로 고정한다. 검증을 통과한 tag는 성공 로그에 출력하지
않지만, 이후 GitHub CLI가 반환하는 외부 오류 문구까지 숨긴다는 계약은
아니다.
- GitHub commits API는 lightweight tag와 annotated tag를 모두 commit으로
해석하므로 별도 tag-peeling 구현은 두지 않는다.

## 남은 경계

- mutable release에서는 검증 직후 외부 주체가 tag를 다시 바꿀 수 있다.
live guard와 다음 upload 호출은 원자적이지 않으며, guard 뒤 release나
asset이 삭제될 수도 있다. 파일 검사와 upload 사이의 로컬 변경도 하나의
원자 연산이 아니다. 따라서 exact-head 원자성이나 게시 성공을 guard만으로
보장한다고 주장하지 않는다. immutable release에서는 tag 변경 경로가
닫히지만, 실제 payload의 `immutable` 값은 증거로만 기록한다.
- 보호 브랜치 push의 snapshot은 계속 같은 correlator를 사용한다. 취소가
늦거나 이미 제출 단계에 들어간 구형 실행이 최신 실행보다 나중에 API에
도착하면 GitHub가 구형 snapshot을 latest로 선택할 가능성은 이 변경에서
해결하지 않는다.
- `queue: max`는 2026-05-07 추가된 GitHub 공식 문법이다. 현재 고정
actionlint 빌드는 그 이전인 2026-04-19 소스라 이 키를 모른다. upstream
actionlint의 지원 PR도 아직 열려 있으므로 해당 lint 실패를 숨기거나
통과로 바꾸지 않는다.

## 근거

- [GitHub Actions concurrency queue](https://docs.github.com/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency)
- [GitHub release 수정 API](https://docs.github.com/rest/releases/releases#update-a-release)
- [GitHub commit 조회 API](https://docs.github.com/rest/commits/commits#get-a-commit)
- [GitHub dependency submission API](https://docs.github.com/rest/dependency-graph/dependency-submission)
- [pinned Anchore release 게시 구현](https://github.com/anchore/sbom-action/blob/e22c389904149dbc22b58101806040fa8d37a610/src/github/SyftGithubAction.ts#L484-L592)
- [pinned Anchore publisher fallback](https://github.com/anchore/sbom-action/blob/e22c389904149dbc22b58101806040fa8d37a610/src/github/SyftGithubAction.ts#L484-L600)
- [GitHub CLI release upload](https://cli.github.com/manual/gh_release_upload)
- [actionlint queue 지원 추적](https://github.com/rhysd/actionlint/pull/654)
Loading
Loading