77 workflow_dispatch :
88 inputs :
99 version :
10- description : ' Release tag (e.g. v0.3.0 or v0.3.0 -beta.1)'
10+ description : ' Release tag (e.g. v0.3.1 or v0.3.1 -beta.1)'
1111 required : true
1212 type : string
1313
1414permissions :
1515 contents : write
1616
1717jobs :
18+ # ─── Job 1: Skip guard + version resolution (cheap, ubuntu-latest) ────────
19+ check :
20+ runs-on : ubuntu-latest
21+ outputs :
22+ skip : ${{ steps.gate.outputs.skip }}
23+ version : ${{ steps.version.outputs.version }}
24+ base_version : ${{ steps.version.outputs.base_version }}
25+ build_number : ${{ steps.version.outputs.build_number }}
26+ tag : ${{ steps.version.outputs.tag }}
27+ is_beta : ${{ steps.version.outputs.is_beta }}
28+
29+ steps :
30+ - uses : actions/checkout@v4
31+
32+ - name : Read version.env
33+ id : ver_env
34+ run : |
35+ set -a; source version.env; set +a
36+ echo "marketing_version=$MARKETING_VERSION" >> "$GITHUB_OUTPUT"
37+ echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
38+
39+ - name : Resolve and validate version
40+ id : version
41+ run : |
42+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
43+ TAG="${{ github.event.inputs.version }}"
44+ else
45+ TAG="${GITHUB_REF_NAME}"
46+ fi
47+
48+ VERSION="${TAG#v}"
49+ BASE_VERSION="${VERSION%%-*}" # strips -beta.1, -rc.1, etc.
50+
51+ IS_BETA="false"
52+ [[ "$TAG" == *"-beta"* ]] && IS_BETA="true"
53+
54+ MVER="${{ steps.ver_env.outputs.marketing_version }}"
55+ if [ "$BASE_VERSION" != "$MVER" ]; then
56+ echo "::error::Tag base version ($BASE_VERSION) ≠ version.env MARKETING_VERSION ($MVER)"
57+ echo "Update version.env (MARKETING_VERSION=$BASE_VERSION) before tagging."
58+ exit 1
59+ fi
60+
61+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
62+ echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT"
63+ echo "build_number=${{ steps.ver_env.outputs.build_number }}" >> "$GITHUB_OUTPUT"
64+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
65+ echo "is_beta=$IS_BETA" >> "$GITHUB_OUTPUT"
66+
67+ - name : Check if release already exists
68+ id : gate
69+ env :
70+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
71+ run : |
72+ TAG="${{ steps.version.outputs.tag }}"
73+ if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" 2>/dev/null; then
74+ echo "Release $TAG already exists — skipping."
75+ echo "skip=true" >> "$GITHUB_OUTPUT"
76+ else
77+ echo "skip=false" >> "$GITHUB_OUTPUT"
78+ fi
79+
80+ # ─── Job 2: Build, package, publish (macos-26) ───────────────────────────
1881 release :
82+ needs : check
83+ if : needs.check.outputs.skip != 'true'
1984 runs-on : macos-26
2085 env :
21- SCHEME : perch
22- APP_NAME : perch
86+ SCHEME : perch
87+ APP_NAME : perch
88+ VERSION : ${{ needs.check.outputs.version }}
89+ BASE_VERSION : ${{ needs.check.outputs.base_version }}
90+ BUILD_NUMBER : ${{ needs.check.outputs.build_number }}
91+ TAG : ${{ needs.check.outputs.tag }}
92+ IS_BETA : ${{ needs.check.outputs.is_beta }}
2393
2494 steps :
2595 - uses : actions/checkout@v4
96+ with :
97+ fetch-depth : 0 # needed for GitHub Contents API push of appcast.xml
2698
2799 - name : Cache DerivedData
28100 uses : actions/cache@v4
@@ -32,27 +104,13 @@ jobs:
32104 restore-keys : |
33105 ${{ runner.os }}-dd-release-
34106
35- - name : Resolve version
36- id : version
37- run : |
38- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
39- TAG="${{ github.event.inputs.version }}"
40- else
41- TAG="${GITHUB_REF_NAME}"
42- fi
43- VERSION="${TAG#v}"
44- BASE_VERSION="${VERSION%%-*}"
45- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
46- echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT"
47- echo "tag=$TAG" >> "$GITHUB_OUTPUT"
48-
49107 - name : Build universal archive
50108 run : |
51109 set -o pipefail
52110 xcodebuild archive \
53111 -scheme "$SCHEME" \
54112 -configuration Release \
55- -archivePath build/$APP_NAME.xcarchive \
113+ -archivePath " build/$APP_NAME.xcarchive" \
56114 -destination 'generic/platform=macOS' \
57115 -derivedDataPath build/DerivedData \
58116 -skipPackagePluginValidation \
@@ -63,33 +121,30 @@ jobs:
63121 CODE_SIGN_IDENTITY="-" \
64122 CODE_SIGNING_REQUIRED=NO \
65123 CODE_SIGNING_ALLOWED=NO \
66- MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \
124+ MARKETING_VERSION="$BASE_VERSION" \
125+ CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
67126 SKIP_INSTALL=NO 2>&1 | tail -100
68127
69128 - name : Ad-hoc sign app bundle
70129 run : |
71130 APP="build/$APP_NAME.xcarchive/Products/Applications/$APP_NAME.app"
72- codesign --force --deep --sign - --timestamp=none "${APP}"
73- echo "Signed: ${APP}"
131+ codesign --force --deep --sign - --timestamp=none "$APP"
132+ echo "Signed: $APP"
133+
134+ - name : Install create-dmg
135+ run : brew install create-dmg
74136
75137 - name : Create DMG
76138 run : |
77- VERSION="${{ steps.version.outputs.version }}"
78139 mkdir -p build/dmg
79- STAGING="build/dmg-staging"
80- mkdir -p "$STAGING"
81- cp -R "build/$APP_NAME.xcarchive/Products/Applications/$APP_NAME.app" "$STAGING/$APP_NAME.app"
82- hdiutil create \
83- -volname "Perch" \
84- -srcfolder "$STAGING" \
85- -ov -format UDZO \
86- "build/dmg/$APP_NAME-${VERSION}.dmg"
87- echo "Created: $APP_NAME-${VERSION}.dmg"
140+ bash scripts/build-dmg.sh \
141+ "$VERSION" \
142+ "build/$APP_NAME.xcarchive/Products/Applications/$APP_NAME.app" \
143+ "build/dmg"
88144
89145 - name : Compose release body
90146 id : body
91147 run : |
92- VERSION="${{ steps.version.outputs.version }}"
93148 {
94149 echo "body<<__BODY__"
95150 echo "## Perch ${VERSION}"
@@ -110,10 +165,53 @@ jobs:
110165 - name : Create GitHub Release
111166 uses : softprops/action-gh-release@v2
112167 with :
113- tag_name : ${{ steps.version.outputs.tag }}
114- name : " Perch ${{ steps.version.outputs.version }}"
115- body : ${{ steps.body.outputs.body }}
116- files : |
117- build/dmg/${{ env.APP_NAME }}-${{ steps.version.outputs.version }}.dmg
118- draft : false
119- prerelease : ${{ contains(steps.version.outputs.tag, 'beta') }}
168+ tag_name : ${{ env.TAG }}
169+ name : " Perch ${{ env.VERSION }}"
170+ body : ${{ steps.body.outputs.body }}
171+ files : build/dmg/${{ env.APP_NAME }}-${{ env.VERSION }}.dmg
172+ draft : false
173+ prerelease : ${{ env.IS_BETA == 'true' }}
174+
175+ - name : Update appcast.xml
176+ env :
177+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
178+ run : |
179+ DMG_PATH="build/dmg/${APP_NAME}-${VERSION}.dmg"
180+ DMG_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${APP_NAME}-${VERSION}.dmg"
181+ DMG_SIZE=$(stat -f%z "$DMG_PATH")
182+
183+ CHANNEL_ARG=""
184+ [[ "$IS_BETA" == "true" ]] && CHANNEL_ARG="beta"
185+
186+ # Always fetch appcast.xml from main branch (not from tag checkout)
187+ if gh api "repos/${GITHUB_REPOSITORY}/contents/appcast.xml" \
188+ --jq '.content' 2>/dev/null | base64 -d > appcast.xml; then
189+ echo "Fetched existing appcast.xml from main"
190+ else
191+ echo "appcast.xml not found on main — using template from checkout"
192+ fi
193+
194+ if [[ -n "$CHANNEL_ARG" ]]; then
195+ python3 scripts/update-appcast.py \
196+ "$VERSION" "$BUILD_NUMBER" "$DMG_URL" "$DMG_SIZE" "$CHANNEL_ARG"
197+ else
198+ python3 scripts/update-appcast.py \
199+ "$VERSION" "$BUILD_NUMBER" "$DMG_URL" "$DMG_SIZE"
200+ fi
201+
202+ # Push updated appcast.xml to main branch via GitHub Contents API
203+ SHA=$(gh api "repos/${GITHUB_REPOSITORY}/contents/appcast.xml" \
204+ --jq '.sha' 2>/dev/null || echo "")
205+
206+ ENCODED=$(base64 -i appcast.xml | tr -d '\n')
207+ jq -n \
208+ --arg message "appcast: add v${VERSION} (build ${BUILD_NUMBER})" \
209+ --arg content "$ENCODED" \
210+ --arg sha "$SHA" \
211+ '{"message": $message, "content": $content}
212+ + (if $sha != "" then {"sha": $sha} else {} end)' \
213+ | gh api "repos/${GITHUB_REPOSITORY}/contents/appcast.xml" \
214+ --method PUT \
215+ --input -
216+
217+ echo "appcast.xml updated for v${VERSION}"
0 commit comments