Skip to content
Merged
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
138 changes: 124 additions & 14 deletions .github/workflows/deploy-spm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,42 @@ on:
type: string

concurrency:
group: deploy-spm-${{ github.ref }}
group: deploy-spm
cancel-in-progress: false

permissions:
contents: write

jobs:
deploy:
name: Deploy Swift Package
verify-artifact:
name: Verify DiagramMaker artifact (${{ matrix.architecture }})
runs-on: ${{ matrix.runner }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- architecture: arm64
runner: macos-26
- architecture: x86_64
runner: macos-26-intel
env:
DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Verify committed DiagramMaker artifact
run: bash Scripts/verify-diagram-artifact.sh HEAD "${{ matrix.architecture }}"

validate:
name: Validate Swift Package
needs: verify-artifact
runs-on: macos-26
permissions:
contents: read
outputs:
commit_sha: ${{ steps.commit.outputs.sha }}
env:
DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
VERSION: ${{ inputs.version }}
Expand All @@ -33,12 +59,20 @@ jobs:
with:
fetch-depth: 0

- name: Resolve deployment commit
id: commit
shell: bash
run: |
set -euo pipefail
commit_sha="$(git rev-parse "$GITHUB_SHA^{commit}")"
echo "sha=$commit_sha" >> "$GITHUB_OUTPUT"

- name: Validate version
shell: bash
run: |
set -euo pipefail

if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "Version must use semantic versioning like 1.0.0" >&2
exit 1
fi
Expand Down Expand Up @@ -71,6 +105,11 @@ jobs:
- name: Swift Version
run: swift --version

- name: Verify remote Cradle consumer by revision
env:
COMMIT_SHA: ${{ steps.commit.outputs.sha }}
run: bash Scripts/verify-cradle-consumer.sh revision "$COMMIT_SHA" "$COMMIT_SHA"

- name: Test
shell: bash
run: |
Expand All @@ -90,40 +129,111 @@ jobs:
path: deploy-test.log
if-no-files-found: ignore

- name: Create and push tag
publish:
name: Publish Swift Package
needs: validate
runs-on: macos-26
permissions:
contents: write
env:
DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
VERSION: ${{ inputs.version }}
RELEASE_NOTES: ${{ inputs.release_notes }}
COMMIT_SHA: ${{ needs.validate.outputs.commit_sha }}
steps:
- name: Checkout verified commit
uses: actions/checkout@v5
with:
ref: ${{ needs.validate.outputs.commit_sha }}
fetch-depth: 0

- name: Ensure tag does not already exist
shell: bash
run: |
set -euo pipefail

git fetch --tags --force origin
if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then
echo "Tag already exists locally: $VERSION" >&2
exit 1
fi

set +e
remote_tags="$(git ls-remote --tags origin "refs/tags/$VERSION")"
remote_status=$?
set -e

if [[ "$remote_status" -ne 0 ]]; then
echo "Unable to check remote tags" >&2
exit "$remote_status"
fi

if [[ -n "$remote_tags" ]]; then
echo "Tag already exists on origin: $VERSION" >&2
exit 1
fi

- name: Create and push verified tag
shell: bash
run: |
set -euo pipefail

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
if [[ "$(git rev-parse "$COMMIT_SHA^{commit}")" != "$COMMIT_SHA" ]]; then
echo "Verified commit is unavailable" >&2
exit 1
fi

git tag -a "$VERSION" "$COMMIT_SHA" -m "Release $VERSION"
git push origin "refs/tags/$VERSION"

- name: Verify pushed tag revision
shell: bash
run: |
set -euo pipefail

remote_revision="$(git ls-remote --tags origin "refs/tags/$VERSION^{}" | awk '{print $1}')"
if [[ "$remote_revision" != "$COMMIT_SHA" ]]; then
echo "Pushed tag does not point to the verified commit" >&2
exit 1
fi

- name: Verify remote Cradle consumer by version
run: bash Scripts/verify-cradle-consumer.sh exact "$VERSION" "$COMMIT_SHA"

- name: Create GitHub release
uses: actions/github-script@v8
env:
VERSION: ${{ env.VERSION }}
RELEASE_NOTES: ${{ env.RELEASE_NOTES }}
COMMIT_SHA: ${{ env.COMMIT_SHA }}
with:
script: |
const version = process.env.VERSION;
const releaseNotes = process.env.RELEASE_NOTES?.trim();
const commitSHA = process.env.COMMIT_SHA;
const body = releaseNotes && releaseNotes.length > 0
? releaseNotes
: [
`Swift Package release ${version}`,
`Cradle ${version} 배포`,
'',
`- Ref: ${context.ref}`,
`- Commit: ${context.sha}`,
`- 기준 commit: ${commitSHA}`,
].join('\n');

await github.rest.repos.createRelease({
const response = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: version,
name: version,
body,
generate_release_notes: !releaseNotes,
});

const release = response.data;
if (release.tag_name !== version || release.draft || !release.html_url) {
throw new Error('Published Release verification failed');
}

core.info(`Published Release: ${release.html_url}`);
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,74 @@

Cradle은 Swift Macro를 사용해 의존성 graph의 factory를 생성하는 라이브러리입니다.

## 요구 사항

- Swift tools 6.3
- iOS 17 이상 또는 macOS 10.15 이상
- `CradlePlugin` 사용 시 arm64 또는 x86_64 macOS 빌드 호스트

## 설치

### Swift Package Manager

`Package.swift`의 dependencies에 Cradle을 추가합니다. 아래 `1.0.0`은 배포가 완료된 버전 예시이며, 최초 tag가 게시되기 전에는 실제 설치에 사용할 수 없습니다.

```swift
dependencies: [
.package(url: "https://github.com/opficdev/Cradle.git", from: "1.0.0")
]
```

앱 또는 라이브러리 target에는 `Cradle` product를 연결합니다.

```swift
.target(
name: "AppComposition",
dependencies: [
.product(name: "Cradle", package: "Cradle")
]
)
```

`CradleTesting`은 테스트 target에서만 선택적으로 연결합니다. graph 선언과 `.mock` 편의 API를 함께 사용하려면 `Cradle`도 같은 target에 추가합니다.

```swift
.testTarget(
name: "AppCompositionTests",
dependencies: [
.product(name: "Cradle", package: "Cradle"),
.product(name: "CradleTesting", package: "Cradle")
]
)
```

`CradlePlugin`은 import하는 라이브러리가 아니라 macOS 빌드 호스트에서 실행하는 Build Tool Plugin입니다. Mermaid 개발 산출물이 필요한 target에만 연결합니다.

```swift
.target(
name: "AppComposition",
dependencies: [
.product(name: "Cradle", package: "Cradle")
],
plugins: [
.plugin(name: "CradlePlugin", package: "Cradle")
]
)
```

`@DependencyGraph`, `@Provide`, `@External`의 사용 조건과 생성 멤버 계약은 [DependencyGraph 안내](Sources/Cradle/Cradle.docc/DependencyGraph.md)에서 확인할 수 있습니다. 테스트 대역 교체는 [CradleTesting 안내](Sources/CradleTesting/CradleTesting.docc/CradleTesting.md)를 참고합니다.

## 배포

관리자는 Actions의 `Deploy SPM`을 수동으로 실행하고, 접두사 없는 `version`과 선택 `release_notes`를 입력합니다. 배포 흐름은 다음 순서로 진행합니다.

1. `version` 형식과 기존 tag를 확인합니다.
2. artifact, 원격 revision 소비자, 전체 test를 검증합니다.
3. 검증한 commit에 annotated tag를 만들고 원격 tag revision을 확인합니다.
4. exact version 소비자를 검증한 뒤 GitHub Release를 생성하고 게시 상태를 확인합니다.

tag push 뒤 exact version 소비자 검증이나 Release 생성에 실패하면 tag는 그대로 남고 GitHub Release는 생성되지 않습니다. 배포는 tag를 삭제하거나 이동하지 않으므로, 원인을 확인한 뒤 기존 tag를 기준으로 별도 Release 절차를 진행해야 합니다.

## Mermaid 개발 산출물

`CradlePlugin`은 build 중 Mermaid 원본을 생성합니다. Xcode에서 바로 열 파일이 필요하면 target의 마지막 Run Script 단계가 `.cradle/DependencyGraph.mmd`로 복사하게 설정할 수 있습니다.
Expand Down
87 changes: 87 additions & 0 deletions Scripts/verify-cradle-consumer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/bin/bash
set -euo pipefail

# Git URL 기반 Cradle과 CradlePlugin 소비자 build 검증
if [[ "$#" -ne 3 ]]; then
echo "Usage: $0 <revision|exact> <reference> <expected-revision>" >&2
exit 1
fi

mode="$1"
reference="$2"
expected_revision="$3"
if [[ ! "$expected_revision" =~ ^[0-9a-f]{40}$ ]]; then
echo "Expected revision must be a 40-character commit hash" >&2
exit 1
fi

case "$mode" in
revision)
if [[ ! "$reference" =~ ^[0-9a-f]{40}$ ]]; then
echo "Revision must be a 40-character commit hash" >&2
exit 1
fi
;;
exact)
if [[ ! "$reference" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "Version must use semantic versioning like 1.0.0" >&2
exit 1
fi
;;
*)
echo "Unsupported dependency mode: $mode" >&2
exit 1
;;
esac

repo_dir="$(cd "$(dirname "$0")/.." && pwd)"
temporary="$(mktemp -d "${TMPDIR:-/tmp}/cradle-consumer.XXXXXX")"
trap 'rm -rf "$temporary"' EXIT
fixture="$temporary/CradlePluginConsumer"
manifest="$fixture/Package.swift"

cp -R "$repo_dir/Tests/IntegrationFixtures/CradlePluginConsumer" "$fixture"

CRADLE_DEPENDENCY_MODE="$mode" CRADLE_DEPENDENCY_REFERENCE="$reference" perl -0pi -e '
my $mode = $ENV{CRADLE_DEPENDENCY_MODE};
my $reference = $ENV{CRADLE_DEPENDENCY_REFERENCE};
my $replacement = qq{.package(url: "https://github.com/opficdev/Cradle.git", $mode: "$reference")};
my $count = s{\.package\(path: "\.\./\.\./\.\."\)}{$replacement};
die "Unable to replace local Cradle dependency\n" unless $count == 1;
' "$manifest"

swift build \
--package-path "$fixture" \
--scratch-path "$temporary/scratch"

resolved="$fixture/Package.resolved"
if [[ ! -f "$resolved" ]]; then
echo "Missing resolved package state" >&2
exit 1
fi

resolved_revision="$(ruby -rjson -e '
resolved = JSON.parse(File.read(ARGV.fetch(0)))
pin = resolved.fetch("pins").find { |candidate| candidate.fetch("identity") == "cradle" }
abort "Missing Cradle resolved package" if pin.nil?
print pin.fetch("state").fetch("revision")
' "$resolved")"
if [[ "$resolved_revision" != "$expected_revision" ]]; then
echo "Resolved revision does not match expected revision" >&2
exit 1
fi

if [[ "$mode" == "exact" ]]; then
resolved_version="$(ruby -rjson -e '
resolved = JSON.parse(File.read(ARGV.fetch(0)))
pin = resolved.fetch("pins").find { |candidate| candidate.fetch("identity") == "cradle" }
abort "Missing Cradle resolved package" if pin.nil?
print pin.fetch("state").fetch("version")
' "$resolved")"
if [[ "$resolved_version" != "$reference" ]]; then
echo "Resolved version does not match requested version" >&2
exit 1
fi
fi

echo "Cradle consumer build and resolved revision verification succeeded for $mode $reference"
Loading