also looking at this
fix(release): one publisher, and a red run when the DMG is missing
#58
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.
| @@ -121,7 +121,8 @@ buddy lint # runs linter | ||
| 121 | 121 | buddy lint:fix # runs linter and fixes issues |
| 122 | 122 | |
| 123 | 123 | buddy commit # follow CLI prompts for committing staged changes |
| 124 | buddy release # creates the releases for the stack & triggers the Release Action (workflow) | |
| 124 | buddy uplink:release # the only thing that publishes Uplink: builds, signs, notarizes and attaches the DMG (macOS only) | |
| 125 | buddy release # bumps the version and tags; it does NOT publish, and a tag with no DMG fails the Release workflow | |
| 125 | 126 | buddy changelog # generates CHANGELOG.md |
| 126 | 127 | |
| 127 | 128 | # building for production (e.g. npm, Vercel, Netlify, et al.) |
| @@ -5,38 +5,72 @@ on: | ||
| 5 | 5 | tags: |
| 6 | 6 | - 'v*' |
| 7 | 7 | |
| 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). | |
| 8 | 17 | permissions: |
| 9 | contents: write | |
| 18 | contents: read | |
| 10 | 19 | |
| 11 | 20 | jobs: |
| 12 | release: | |
| 21 | dmg-attached: | |
| 22 | name: The release has a DMG | |
| 13 | 23 | runs-on: ubuntu-latest |
| 14 | 24 | |
| 15 | 25 | 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 | |
| 41 | 31 | 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 | |
| @@ -41,6 +41,13 @@ async function openMessages(config: ReturnType<typeof loadConfig>, appDir: strin | ||
| 41 | 41 | * key, and the tables runs are recorded in. Both steps are idempotent, so |
| 42 | 42 | * install can always run them. |
| 43 | 43 | */ |
| 44 | const CHANGELOG_HEADING = '# Uplink Changelog' | |
| 45 | ||
| 46 | /** A changelog file without its heading, so two of them can be stacked. */ | |
| 47 | function changelogBody(markdown: string): string { | |
| 48 | return markdown.replace(CHANGELOG_HEADING, '').trim() | |
| 49 | } | |
| 50 | ||
| 44 | 51 | function prepareApp(appDir: string): void { |
| 45 | 52 | const buddy = `${appDir}/buddy` |
| 46 | 53 | if (!existsSync(`${appDir}/.env`)) { |
| @@ -243,20 +250,35 @@ export default defineCommand((cli) => { | ||
| 243 | 250 | url: 'https://github.com/stacksjs/uplink/releases/latest/download/Uplink.dmg', |
| 244 | 251 | }, null, 2)}\n`) |
| 245 | 252 | |
| 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 | ||
| 246 | 263 | // Committed and pushed here rather than left as a note to whoever is |
| 247 | 264 | // 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() | |
| 253 | 269 | if (pending) { |
| 254 | step(['git', 'add', 'resources/data/release.json']) | |
| 270 | step(['git', 'add', 'resources/data/release.json', 'CHANGELOG.md']) | |
| 255 | 271 | step(['git', 'commit', '-m', `chore: Uplink ${version} is out`]) |
| 256 | 272 | step(['git', 'push']) |
| 257 | 273 | } |
| 258 | 274 | |
| 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'] : [])]) | |
| 260 | 282 | process.exit(0) |
| 261 | 283 | }) |
| 262 | 284 | |