diff --git a/.github/workflows/deploy-spm.yml b/.github/workflows/deploy-spm.yml index 8081694..10059a6 100644 --- a/.github/workflows/deploy-spm.yml +++ b/.github/workflows/deploy-spm.yml @@ -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 }} @@ -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 @@ -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: | @@ -90,7 +129,51 @@ 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 @@ -98,28 +181,48 @@ jobs: 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, @@ -127,3 +230,10 @@ jobs: 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}`); diff --git a/README.md b/README.md index b2e00cf..fde807a 100644 --- a/README.md +++ b/README.md @@ -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`로 복사하게 설정할 수 있습니다. diff --git a/Scripts/verify-cradle-consumer.sh b/Scripts/verify-cradle-consumer.sh new file mode 100644 index 0000000..ad0e0d3 --- /dev/null +++ b/Scripts/verify-cradle-consumer.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -euo pipefail + +# Git URL 기반 Cradle과 CradlePlugin 소비자 build 검증 +if [[ "$#" -ne 3 ]]; then + echo "Usage: $0 " >&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" diff --git a/Scripts/verify-diagram-artifact.sh b/Scripts/verify-diagram-artifact.sh new file mode 100644 index 0000000..af0ec6a --- /dev/null +++ b/Scripts/verify-diagram-artifact.sh @@ -0,0 +1,93 @@ +#!/bin/bash +set -euo pipefail + +# 배포 대상 commit의 Maker source와 artifact 소비 가능 여부 검증 +repo_dir="$(cd "$(dirname "$0")/.." && pwd)" +revision="${1:-HEAD}" +if [[ "$#" -gt 2 ]]; then + echo "Usage: $0 [revision] [architecture]" >&2 + exit 1 +fi + +target_architecture="${2:-$(uname -m)}" +case "$target_architecture" in + arm64 | x86_64) + ;; + *) + echo "Unsupported artifact architecture: $target_architecture" >&2 + exit 1 + ;; +esac + +host_architecture="$(uname -m)" +if [[ "$host_architecture" != "$target_architecture" ]]; then + echo "Artifact architecture must match host architecture" >&2 + exit 1 +fi + +temporary="$(mktemp -d "${TMPDIR:-/tmp}/cradle-diagram-artifact.XXXXXX")" +package_dir="$temporary/Cradle" +trap 'rm -rf "$temporary"' EXIT + +mkdir -p "$package_dir" +git -C "$repo_dir" archive --format=tar "$revision" | tar -x -C "$package_dir" + +artifacts=( + "Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-arm64/bin/CradleDiagramMaker" + "Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-x86_64/bin/CradleDiagramMaker" +) +architectures=(arm64 x86_64) + +for index in "${!artifacts[@]}"; do + artifact="${artifacts[$index]}" + path="$package_dir/$artifact" + if [[ ! -x "$path" ]]; then + echo "Missing executable artifact: $artifact" >&2 + exit 1 + fi + if ! file "$path" | grep -q "${architectures[$index]}"; then + echo "Unexpected artifact architecture: $artifact" >&2 + exit 1 + fi +done + +stored_tool="$temporary/stored-CradleDiagramMaker" +cp "$package_dir/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-$target_architecture/bin/CradleDiagramMaker" "$stored_tool" + +bash "$package_dir/Scripts/build-diagram-artifact.sh" + +for index in "${!artifacts[@]}"; do + artifact="${artifacts[$index]}" + path="$package_dir/$artifact" + if [[ ! -x "$path" ]] || ! file "$path" | grep -q "${architectures[$index]}"; then + echo "Source build did not create expected artifact: $artifact" >&2 + exit 1 + fi +done + +fixture="$package_dir/Tests/IntegrationFixtures/CradlePluginConsumer" +scratch="$temporary/consumer-scratch" +source_file="$fixture/Sources/AppComposition/AppGraph.swift" +stored_output="$temporary/stored-output" +source_output="$temporary/source-output" +source_tool="$package_dir/Artifacts/CradleDiagramMaker.artifactbundle/CradleDiagramMaker-$target_architecture/bin/CradleDiagramMaker" + +"$stored_tool" --module AppComposition --output "$stored_output" "$source_file" +"$source_tool" --module AppComposition --output "$source_output" "$source_file" + +stored_diagram="$stored_output/AppComposition/DependencyGraph.mmd" +source_diagram="$source_output/AppComposition/DependencyGraph.mmd" +if ! cmp -s "$stored_diagram" "$source_diagram"; then + echo "Committed artifact output does not match source-built artifact" >&2 + exit 1 +fi + +swift build --package-path "$fixture" --scratch-path "$scratch" + +diagram="$(find "$scratch" -name DependencyGraph.mmd -type f -print -quit)" +if [[ -z "$diagram" ]] || ! grep -q "AppGraph" "$diagram" || ! grep -q "ExplicitGraph" "$diagram"; then + echo "Source-built artifact did not create expected Mermaid output" >&2 + exit 1 +fi + +echo "CradleDiagramMaker $target_architecture artifact passed consumer verification for $revision"