ReviewOS

also looking at this

stacks/uplink

fix(release): one publisher, and a red run when the DMG is missing

#58
Merged glennmichael123 wants to merge fix/one-release-publisher into main
3 files +93 -36

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/CONTRIBUTING.mdmodified+2-1
Changes to .github/CONTRIBUTING.md
@@ -121,7 +121,8 @@ buddy lint # runs linter
121121buddy lint:fix # runs linter and fixes issues
122122
123123buddy commit # follow CLI prompts for committing staged changes
124buddy release # creates the releases for the stack & triggers the Release Action (workflow)
124buddy uplink:release # the only thing that publishes Uplink: builds, signs, notarizes and attaches the DMG (macOS only)
125buddy release # bumps the version and tags; it does NOT publish, and a tag with no DMG fails the Release workflow
125126buddy changelog # generates CHANGELOG.md
126127
127128# building for production (e.g. npm, Vercel, Netlify, et al.)
.github/workflows/release.ymlmodified+62-28
Changes to .github/workflows/release.yml
@@ -5,38 +5,72 @@ on:
55 tags:
66 - 'v*'
77
8# Read only. This workflow publishes nothing: `buddy uplink:release` on a Mac
9# is the only publisher, because the DMG has to be signed with a Developer ID
10# and notarized by Apple, and no ubuntu runner can do that.
11#
12# It used to run `stacksjs/action-releaser`, which created a release for the
13# same tag and attached nothing. Whichever finished last decided what the
14# release page said, and a tag pushed without the manual step still produced
15# the release GitHub then calls `latest`, so every download 404'd with nothing
16# failing anywhere to say so (#32).
817permissions:
9 contents: write
18 contents: read
1019
1120jobs:
12 release:
21 dmg-attached:
22 name: The release has a DMG
1323 runs-on: ubuntu-latest
1424
1525 steps:
16 - name: Checkout Code
17 uses: actions/checkout@v6
18 with:
19 # The changelog is generated from commits since the previous tag, so
20 # the full history has to be present.
21 fetch-depth: 0
22
23 - name: Setup Pantry
24 uses: pantry-pm/pantry/packages/action@fa35547da630b14beda5b26a2625490864d5c963 # v0.11.60
25 with:
26 install: 'false'
27
28 - name: Use Cached node_modules
29 uses: actions/cache@v5
30 with:
31 path: node_modules
32 key: node-modules-${{ hashFiles('**/bun.lock') }}
33 restore-keys: |
34 node-modules-
35
36 - name: Install Dependencies
37 run: bun install
38
39 - name: Create GitHub Release
40 uses: stacksjs/action-releaser@v1.2.9
26 # `gh release create` makes the tag and the release, then uploads about
27 # 35 MB, so this run starts while the upload is still going. Polling is
28 # the point: checking once would race the publisher that is working
29 # correctly.
30 - name: Wait for Uplink.dmg to be attached
4131 env:
42 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33 TAG: ${{ github.ref_name }}
34 REPO: ${{ github.repository }}
35 run: |
36 set -uo pipefail
37 for attempt in $(seq 1 30); do
38 release=$(gh release view "$TAG" --repo "$REPO" --json isDraft,assets 2>/dev/null) || release=''
39
40 if [ -n "$release" ]; then
41 # A draft never becomes `latest`, so it cannot 404 a download.
42 if [ "$(echo "$release" | jq -r '.isDraft')" = 'true' ]; then
43 echo "$TAG is a draft. Nothing points at it yet, so there is nothing to check."
44 exit 0
45 fi
46 if echo "$release" | jq -e '.assets[] | select(.name == "Uplink.dmg")' >/dev/null; then
47 echo "$TAG has Uplink.dmg. releases/latest/download/Uplink.dmg resolves."
48 exit 0
49 fi
50 fi
51
52 echo "attempt $attempt/30: no Uplink.dmg on $TAG yet, waiting 20s"
53 sleep 20
54 done
55
56 cat <<EOF
57 ::error::$TAG has no Uplink.dmg after 10 minutes.
58
59 Every download link in the project goes through
60 releases/latest/download/Uplink.dmg: the README button and the three
61 on the homepage. A published release without that asset moves
62 'latest' and 404s all of them.
63
64 This usually means the tag was pushed without running the publisher.
65 Only 'buddy uplink:release' on a Mac can attach the DMG, because it
66 signs with a Developer ID and notarizes with Apple.
67
68 Either attach the asset to the existing release:
69
70 gh release upload $TAG storage/framework/desktop-dmg/Uplink.dmg
71
72 or take the tag back so 'latest' stops pointing at it:
73
74 gh release delete $TAG --cleanup-tag
75 EOF
76 exit 1
app/Commands/Uplink.tsmodified+29-7
Changes to app/Commands/Uplink.ts
@@ -41,6 +41,13 @@ async function openMessages(config: ReturnType<typeof loadConfig>, appDir: strin
4141 * key, and the tables runs are recorded in. Both steps are idempotent, so
4242 * install can always run them.
4343 */
44const CHANGELOG_HEADING = '# Uplink Changelog'
45
46/** A changelog file without its heading, so two of them can be stacked. */
47function changelogBody(markdown: string): string {
48 return markdown.replace(CHANGELOG_HEADING, '').trim()
49}
50
4451function prepareApp(appDir: string): void {
4552 const buddy = `${appDir}/buddy`
4653 if (!existsSync(`${appDir}/.env`)) {
@@ -243,20 +250,35 @@ export default defineCommand((cli) => {
243250 url: 'https://github.com/stacksjs/uplink/releases/latest/download/Uplink.dmg',
244251 }, null, 2)}\n`)
245252
253 // `buddy changelog` rewrites the file with the commits since the last
254 // tag and nothing else, so the older entries are carried over by hand.
255 // A changelog that forgets every release but the newest is not one.
256 const changelogPath = `${appDir}/CHANGELOG.md`
257 const kept = changelogBody(await Bun.file(changelogPath).text())
258 step(['./buddy', 'changelog', '--no-interaction'])
259 const generated = changelogBody(await Bun.file(changelogPath).text())
260 const sections = [`## v${version}\n`, `${generated}\n`, kept && `${kept}\n`].filter(Boolean)
261 await Bun.write(changelogPath, `${CHANGELOG_HEADING}\n\n${sections.join('\n')}`)
262
246263 // Committed and pushed here rather than left as a note to whoever is
247264 // releasing. `gh` cuts the tag from the remote tip, so this has to land
248 // first or the tagged commit does not contain its own release metadata,
249 // and the site keeps advertising the previous version until somebody
250 // remembers. Skipped when nothing changed, so re-running a release for
251 // the same version is not an error.
252 const pending = Bun.spawnSync(['git', 'status', '--porcelain', 'resources/data/release.json'], { cwd: appDir }).stdout.toString().trim()
265 // first or the tagged commit does not contain its own release notes or
266 // the version the site advertises. Skipped when nothing changed, so
267 // re-running a release for the same version is not an error.
268 const pending = Bun.spawnSync(['git', 'status', '--porcelain', 'resources/data/release.json', 'CHANGELOG.md'], { cwd: appDir }).stdout.toString().trim()
253269 if (pending) {
254 step(['git', 'add', 'resources/data/release.json'])
270 step(['git', 'add', 'resources/data/release.json', 'CHANGELOG.md'])
255271 step(['git', 'commit', '-m', `chore: Uplink ${version} is out`])
256272 step(['git', 'push'])
257273 }
258274
259 step(['gh', 'release', 'create', `v${version}`, stable, built, '--title', `Uplink ${version}`, '--notes', `Uplink ${version} for macOS 13 or later. Signed with a Developer ID and notarized by Apple.`, ...(options.draft ? ['--draft'] : [])])
275 // The only thing that writes a release body. The workflow used to create
276 // a second release for the same tag with its own generated notes, so
277 // which text a reader got came down to which of the two finished last.
278 const notesPath = `${appDir}/storage/framework/desktop-dmg/notes.md`
279 await Bun.write(notesPath, `Uplink ${version} for macOS 13 or later. Signed with a Developer ID and notarized by Apple.\n\n${generated}\n`)
280
281 step(['gh', 'release', 'create', `v${version}`, stable, built, '--title', `Uplink ${version}`, '--notes-file', notesPath, ...(options.draft ? ['--draft'] : [])])
260282 process.exit(0)
261283 })
262284