diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..de76160 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,134 @@ +name: Prepare release + +# Step 1 of 3 in the release pipeline (docs/RELEASING.md §7): +# +# prepare-release.yml → you edit the PR → tag-on-merge.yml → release.yml +# (this file) (the only prose (pushes vX.Y.Z) (builds, signs, +# step that is notarizes, waits +# still yours) for your approval) +# +# What this does: drafts RELEASE-NOTES.md for the next version and opens a PR with it. +# +# Why a PR rather than a straight commit + tag. scripts/draft-release-notes.mjs fills in every +# FACT (commit range, PRs, previous tag, suite and case counts, the compare URL) and deliberately +# leaves `` markers where judgement is required — which two of fourteen commits +# actually matter, what to lead with, how to frame a change so it is not misread. Its own header +# argues the case: "a changelog auto-generated from commit subjects is the reason most release +# notes go unread." So the tedious, misrememberable 90% is automated and the prose stays human, +# with a PR as the place to write it. tag-on-merge.yml then refuses to tag while any TODO remains, +# so the automation cannot ship scaffolding. +# +# There is NO version file to bump. The release version is derived from the tag at build time +# (`git describe --tags` in scripts/build-macos.sh), so the tag IS the version and this workflow +# only has to produce notes. + +on: + workflow_dispatch: + inputs: + version: + description: "Release version, without the leading v (e.g. 1.0.5)" + required: true + +permissions: + contents: write # push the release/vX.Y.Z branch + pull-requests: write # open the PR + +jobs: + prepare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # draft-release-notes.mjs walks `prevTag..HEAD` and reads `git tag --list`, so a shallow + # clone would silently produce an empty or wrong range — the class of quiet mistake this + # whole pipeline exists to remove. + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + + - name: Validate the version and refuse to reuse a tag + env: + # Through the environment, never interpolated into the script body: `${{ inputs.version }}` + # inline would splice user text straight into the shell. + VERSION: ${{ inputs.version }} + REF: ${{ github.ref_name }} + run: | + # The Run-workflow dropdown lets you pick ANY branch or tag, and the PR this opens targets + # whatever you picked. But tag-on-merge.yml only listens for PRs into develop, so a release + # PR opened anywhere else merges cleanly and then simply never tags — a dead end with no + # error, discovered whenever someone next wonders where the release went. Fail here, where + # the cause is still on screen. (Coupled to the `branches:` filter in tag-on-merge.yml — + # change both together.) + if [ "$REF" != "develop" ]; then + echo "::error::Run this from develop, not \"$REF\" — tag-on-merge.yml only tags PRs merged into develop, so a release PR opened here would never ship." + exit 1 + fi + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::\"$VERSION\" is not X.Y.Z. Pass the version without a leading v." + exit 1 + fi + if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + echo "::error::Tag v$VERSION already exists. Releases are immutable — pick the next version." + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Draft RELEASE-NOTES.md + run: node scripts/draft-release-notes.mjs "$VERSION" --write + + - name: Open the release PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="release/v$VERSION" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add RELEASE-NOTES.md + + # A release whose range contains nothing is a mistake worth catching here rather than + # three workflows later, at the point where it would have published an empty release. + if git diff --cached --quiet; then + echo "::error::draft-release-notes.mjs produced no change — is there anything to release since the last tag?" + exit 1 + fi + + git commit -m "release: draft notes for v$VERSION" + git push --set-upstream origin "$BRANCH" + + # QUOTED heredoc + placeholder, deliberately. The body is full of backticks, so an + # unquoted heredoc would run them as command substitution; a quoted one leaves them + # alone but also leaves $VERSION literal, hence the sed. Written to a file rather than + # inlined so `gh` gets it verbatim, with no second round of shell parsing. + cat > /tmp/pr-body.md <<'BODY' + Drafted by `.github/workflows/prepare-release.yml`. **The facts are filled in; the prose is yours.** + + ### Before merging + + 1. Replace every `` in `RELEASE-NOTES.md` with real prose. + 2. Read the **excluded as internal** list at the bottom — anything user-visible in there belongs in the notes. + 3. Delete the whole scaffolding block (everything under `EVERYTHING BELOW IS SCAFFOLDING`). + + `tag-on-merge.yml` refuses to tag while any TODO or scaffolding marker survives, so a half-finished + draft cannot reach users. + + ### What merging does + + Merging this PR pushes the `v__VERSION__` tag, which starts `release.yml`: both arches build on native + runners, get signed with the Developer ID cert and notarized by Apple, and land on a **draft** release + with these notes as the body. + + It then **waits for your approval** in the Actions tab before publishing — because publishing is + deploying. Auto-update installs a published release on every existing install at its next check, and + that cannot be reversed for anyone who already took it (`docs/RELEASING.md` §5). Download the dmg from + the draft and launch it on a real Mac before you approve. + BODY + sed -i "s/__VERSION__/$VERSION/g" /tmp/pr-body.md + + gh pr create \ + --base "${{ github.ref_name }}" \ + --head "$BRANCH" \ + --title "release: v$VERSION" \ + --body-file /tmp/pr-body.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7d6dd9..bf0002c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,32 @@ name: Build release apps -# Hybrid release model (see docs/RELEASING.md): CI does the heavy, awkward part — building BOTH -# macOS arches on their NATIVE runners (you can't easily build x64 on an Apple-silicon Mac) — with -# NO signing secrets. It produces unsigned .app bundles and attaches them to a DRAFT release. You -# then sign + notarize + staple LOCALLY (your Developer ID cert never leaves your machine), attach -# the notarized dmgs, delete the UNSIGNED-*.app.zip assets, and publish. +# Step 3 of 3 (docs/RELEASING.md §7): prepare-release.yml → tag-on-merge.yml → THIS. +# +# CI now does the whole pipeline — build both macOS arches on their NATIVE runners (you can't +# easily build x64 on an Apple-silicon Mac), sign with the Developer ID cert, notarize with Apple, +# staple, and attach the four release assets to a DRAFT release carrying RELEASE-NOTES.md. +# +# Then it STOPS and waits for you. +# +# That pause is the point, not a limitation. Publishing a release is deploying it: the built-in +# Squirrel updater installs a published release on every existing install at its next check, with +# no staged rollout, and the rollback pin "cannot un-update anyone who already took it" +# (docs/RELEASING.md §5). So the publish job runs in the `release` GitHub Environment, which +# requires a reviewer. Download the draft's dmg, launch it on a real Mac, then approve. +# +# THE SIGNING SECRETS (repo → Settings → Secrets and variables → Actions): +# APPLE_CERT_P12_BASE64 Developer ID Application cert + private key, exported as .p12 +# from Keychain Access, then `base64 -i cert.p12 | pbcopy`. +# APPLE_CERT_PASSWORD the password you set on that .p12 export. +# APPLE_SIGNING_IDENTITY e.g. "Developer ID Application: NAME (TEAMID)" — exactly as +# `security find-identity -v -p codesigning` prints it. +# APPLE_ID the Apple ID that owns the Developer Program membership. +# APPLE_TEAM_ID the (TEAMID) from the identity string. +# APPLE_APP_SPECIFIC_PASSWORD appleid.apple.com → Sign-In and Security → App-Specific Passwords. +# +# scripts/notarize.sh already supported this path — it takes APPLE_ID + TEAM_ID + +# APP_SPECIFIC_PASSWORD as the alternative to a local `NOTARY_PROFILE` keychain profile — so +# nothing in the build scripts changed to enable CI signing. on: push: @@ -91,6 +113,11 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" steps: - uses: actions/checkout@v7 + with: + # build-macos.sh stamps the release version from `git describe --tags`. A shallow clone + # can leave that resolving to nothing, and its failure mode is SILENT — it warns and + # ships a build whose About box reads "1.126.0", the Code-OSS base. Fetch everything. + fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: "24" # bootstrap.sh checks the .nvmrc major; 24.x satisfies the 1.126 pin @@ -106,48 +133,173 @@ jobs: VSCODE_TAG: ${{ github.event.inputs.vscode_tag }} - name: Build LevelCode.app (${{ matrix.arch }}, proprietary stripped) run: ./scripts/build-macos.sh ${{ matrix.arch }} - - name: Zip the unsigned app - run: ditto -c -k --sequesterRsrc --keepParent "VSCode-darwin-${{ matrix.arch }}/LevelCode.app" "UNSIGNED-LevelCode-${{ matrix.arch }}.app.zip" - - name: Upload app artifact + + - name: Import the Developer ID certificate + env: + CERT_P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }} + CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + run: | + # A DEDICATED keychain in RUNNER_TEMP, never the login keychain. The runner is ephemeral, + # but this also keeps the cert out of anything a later step might enumerate, and makes + # cleanup a single file delete. + KEYCHAIN="$RUNNER_TEMP/levelcode-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/certificate.p12" + + echo "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + # Default keychains re-lock after 5 minutes of inactivity; notarization waits on Apple for + # longer than that, and a re-locked keychain fails the NEXT codesign call with a misleading + # "user interaction is not allowed". 6h, and no auto-lock on sleep. + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security + # Without this, codesign blocks on a GUI "allow access to your keychain?" prompt that no + # one is there to click, and the job hangs until it times out rather than failing. + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null + # Put it on the search list so codesign finds the identity by name. Note the PLURAL + # subcommand: `security` accepts unambiguous prefixes, so `list-keychain` happens to work + # today, but that is prefix-matching rather than a real name — one more Apple subcommand + # starting `list-keychain…` and it becomes ambiguous. + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + + rm -f "$CERT_PATH" + + # Prove the identity the secrets describe is actually present, here — rather than 40 + # minutes later inside notarize.sh with a vaguer error. + security find-identity -v -p codesigning "$KEYCHAIN" | grep -q "Developer ID Application" \ + || { echo "::error::No 'Developer ID Application' identity in the imported keychain — check APPLE_CERT_P12_BASE64 / APPLE_CERT_PASSWORD."; exit 1; } + + - name: Sign, notarize, staple, and package (${{ matrix.arch }}) + env: + CODESIGN_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + # notarize.sh takes these three as the CI alternative to a stored NOTARY_PROFILE. + APPLE_ID: ${{ secrets.APPLE_ID }} + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + # Emits BOTH release assets for this arch: LevelCode-.dmg (humans) and + # LevelCode-.app.zip (the Squirrel update feed). The .app.zip is written only on the + # Developer-ID path, because Squirrel refuses an update whose signing identity differs from + # the running app — so an ad-hoc build must never be served as one. + run: ./scripts/make-dmg.sh ${{ matrix.arch }} + + - name: Verify the signature and the stapled ticket + # Independent of make-dmg.sh's own checks: this is the assertion that what we are about to + # hand every existing install will actually launch on a clean Mac, offline. An un-stapled + # app passes codesign and still fails Gatekeeper on first run. + run: | + APP="VSCode-darwin-${{ matrix.arch }}/LevelCode.app" + codesign --verify --deep --strict --verbose=2 "$APP" + xcrun stapler validate "$APP" + xcrun stapler validate "LevelCode-${{ matrix.arch }}.dmg" + spctl --assess --type execute --verbose "$APP" + + - name: Upload the signed release assets uses: actions/upload-artifact@v7 with: - name: UNSIGNED-LevelCode-${{ matrix.arch }} - path: UNSIGNED-LevelCode-${{ matrix.arch }}.app.zip + name: LevelCode-${{ matrix.arch }} + path: | + LevelCode-${{ matrix.arch }}.dmg + LevelCode-${{ matrix.arch }}.app.zip if-no-files-found: error retention-days: 14 + - name: Delete the signing keychain + # `always()` so a failed build still takes the cert with it. + if: always() + run: security delete-keychain "$RUNNER_TEMP/levelcode-signing.keychain-db" || true + draft-release: name: Draft release needs: build if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: + # RELEASE-NOTES.md at the tag IS the release body. tag-on-merge.yml has already refused to + # create this tag unless the notes are finished and name this version, so there is nothing + # left to check here. + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: path: apps merge-multiple: true - - name: Create / refresh the draft release with the UNSIGNED apps + + - name: Check all four assets arrived + # `fail_on_unmatched_files` below only catches a glob matching NOTHING. Losing one arch — + # the x64 job failing while arm64 succeeds — would still publish, and would silently strand + # every Intel user on their current version, because the update feed serves per-arch. + run: | + MISSING=0 + for f in LevelCode-arm64.dmg LevelCode-x64.dmg LevelCode-arm64.app.zip LevelCode-x64.app.zip; do + if [ -f "apps/$f" ]; then + echo " ok $f ($(du -h "apps/$f" | cut -f1))" + else + echo "::error::missing release asset: $f" + MISSING=1 + fi + done + [ "$MISSING" -eq 0 ] || exit 1 + + - name: Create the draft release uses: softprops/action-gh-release@v2 with: draft: true name: LevelCode ${{ github.ref_name }} tag_name: ${{ github.ref_name }} - files: apps/*.zip + files: | + apps/LevelCode-arm64.dmg + apps/LevelCode-x64.dmg + apps/LevelCode-arm64.app.zip + apps/LevelCode-x64.app.zip fail_on_unmatched_files: true - body: | - **Draft — not for release as-is.** The attached `UNSIGNED-LevelCode-.app.zip` - files are CI build artifacts with **no Developer ID signature or notarization**. - - To finish the release **locally** (your signing cert never touches CI): - - 1. `gh release download ${{ github.ref_name }} --pattern 'UNSIGNED-*.app.zip'` - 2. For each arch — unzip into `VSCode-darwin-/`, then - `CODESIGN_IDENTITY="Developer ID Application: …" NOTARY_PROFILE=levelcode-notary ./scripts/make-dmg.sh ` - (signs → notarizes → staples → `LevelCode-.dmg` **and** `LevelCode-.app.zip`). - 3. `gh release upload ${{ github.ref_name }} LevelCode-arm64.dmg LevelCode-x64.dmg LevelCode-arm64.app.zip LevelCode-x64.app.zip` - — the `.dmg`s are for humans, the `.app.zip`s are the auto-update feed assets (`docs/AUTO-UPDATE.md`). - Upload exactly these four; the `.app.zip.sha256` files `make-dmg.sh` writes stay **local** - (the feed reads GitHub's own asset `digest`, never a sidecar). - 4. **Delete the `UNSIGNED-*.app.zip` assets**, add real notes, and publish. - - Full runbook: `docs/RELEASING.md`. + body_path: RELEASE-NOTES.md + + publish: + name: Publish (requires approval) + needs: draft-release + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + # THE GATE. This environment must have "Required reviewers" configured in + # repo → Settings → Environments → release. Without that setting the job runs immediately and + # the gate is decorative — see docs/RELEASING.md §7 for the one-time setup. + # + # Why a human stands here: publishing is deploying. The Squirrel updater installs a published + # release on every existing install at its next check, there is no staged rollout, and the + # rollback pin cannot un-update anyone who already took it. Everything before this point is + # reversible; this step is not. + environment: release + permissions: + contents: write + steps: + - name: Publish the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release edit "${{ github.ref_name }}" \ + --repo "${{ github.repository }}" \ + --draft=false --latest + echo "::notice::${{ github.ref_name }} is live. Auto-update will begin serving it within ~5 minutes (feed cache)." + + - name: Confirm the update feed picked it up + # The release is only half the story: Levelcode::EditorReleaseFeed (thin.ly) reads + # releases/latest and serves the update endpoint. If this does not flip, users never see + # the release — so surface it here rather than leaving it to a manual curl in the runbook. + run: | + EXPECTED="${GITHUB_REF_NAME#v}" + for attempt in 1 2 3 4 5 6; do + sleep 60 # the feed caches for 5 minutes + BODY="$(curl -fsS -H 'User-Agent: LevelCode Updater' \ + https://levelcode.ai/api/update/darwin-arm64/stable/deadbeef || true)" + case "$BODY" in + *"$EXPECTED"*) + echo "::notice::Update feed is serving $EXPECTED." + exit 0 ;; + esac + echo "attempt $attempt: feed not showing $EXPECTED yet" + done + # A warning, not a failure: the release IS published and correct at this point, and + # failing the job here would imply otherwise. The feed lagging is a thin.ly-side thing to + # go and look at. + echo "::warning::Feed did not report $EXPECTED within 6 minutes — check Levelcode::EditorReleaseFeed and the LEVELCODE_UPDATE_FEED rollback pin." diff --git a/.github/workflows/tag-on-merge.yml b/.github/workflows/tag-on-merge.yml new file mode 100644 index 0000000..9f3570a --- /dev/null +++ b/.github/workflows/tag-on-merge.yml @@ -0,0 +1,93 @@ +name: Tag on release merge + +# Step 2 of 3 (docs/RELEASING.md §7). Merging a `release/vX.Y.Z` PR pushes the tag, which starts +# release.yml. +# +# This exists as its own workflow, and not as a line in prepare-release.yml, because the tag must +# be created from the MERGED state — the notes you actually wrote — rather than from the draft that +# was opened days earlier. The tag is what `git describe --tags` stamps into the built app +# (scripts/build-macos.sh), so it has to point at the commit that carries the finished notes. + +on: + pull_request: + types: [closed] + branches: [develop] + +permissions: + contents: write # push the tag + +jobs: + tag: + # `closed` fires on abandon as well as merge, and only a merge is a release. + if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # The MERGE COMMIT itself, by SHA — not the PR head, and not the base branch by name. + # + # Not the head: the notes are only final once merged. + # Not `base.ref`: that resolves to whatever develop points at when this job starts, and + # anything merged in the seconds or minutes after the release PR would be swept into the + # tag. The build stamps its version from `git describe --tags`, so that ships code the + # release notes do not describe — silently, and only discoverable after the fact. + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + + - name: Take the version from the branch name + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + VERSION="${HEAD_REF#release/v}" + # Re-validate rather than trust the branch name. Anyone who can open a PR chooses this + # string, and it is about to become a git ref and a build-stamped version. + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Branch \"$HEAD_REF\" does not carry an X.Y.Z version — refusing to tag." + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Refuse to tag unfinished notes + # The gate that makes "automate the notes" safe. draft-release-notes.mjs writes TODO markers + # and a scaffolding block precisely so a human fills them in; without this check the pipeline + # would happily publish `` to every user. Publishing is + # deploying here (docs/RELEASING.md §5) — there is no second chance to notice. + run: | + FAILED=0 + # Match the generator's exact marker, `