From 3dde0f9268f5076099ffc34ff1129df2269df884 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 14 Aug 2026 13:16:58 -0400 Subject: [PATCH 1/2] =?UTF-8?q?ci:=20automate=20the=20release=20=E2=80=94?= =?UTF-8?q?=20draft=20notes,=20sign=20in=20CI,=20publish=20behind=20approv?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hybrid model (CI builds unsigned, you sign on your laptop, then hand-juggle six `gh` commands) with three chained workflows. The only manual steps left are the two that need judgement: writing the prose, and deciding to ship. prepare-release.yml → you edit the PR → tag-on-merge.yml → release.yml (drafts the notes) (the prose) (pushes the tag) (builds, signs, notarizes, waits) **Notes.** `draft-release-notes.mjs` already filled in every fact and left TODO markers where judgement is needed — its header argues that a changelog generated from commit subjects is why most release notes go unread. So the workflow opens a PR with that draft rather than committing it, and `tag-on-merge.yml` refuses to tag while a TODO or the scaffolding block survives, or when the first line doesn't name the version being tagged. Automation cannot ship scaffolding, and it cannot ship last release's notes either. **Signing.** `notarize.sh` already took APPLE_ID + TEAM_ID + APP_SPECIFIC_PASSWORD as the CI alternative to a local keychain profile, so no build script changed. The workflow imports the Developer ID cert into a temporary keychain in RUNNER_TEMP, runs the existing `make-dmg.sh`, verifies with codesign + stapler + spctl, and deletes the keychain in an `always()` step. Two details that are load-bearing: `set-key-partition-list` (without it codesign blocks on a GUI prompt nobody can click and the job hangs to timeout) and a 6h keychain lock timeout (the 5-minute default re-locks mid-notarization and the next codesign fails with a misleading "user interaction is not allowed"). Also `fetch-depth: 0` on the build checkout: `build-macos.sh` stamps the version from `git describe --tags`, and its failure mode is silent — it warns and ships a build whose About box reads 1.126.0, the Code-OSS base. **The gate.** Publishing is deploying: the 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. So the publish job sits in a `release` Environment with required reviewers. Everything before it is reversible — a branch, a tag, a draft. That step is not. After publishing it polls the update feed and warns (not fails — the release is already correct) if the feed hasn't picked it up. The draft job also refuses to publish unless all four assets are present. Losing only the x64 job would otherwise strand every Intel user silently, since the feed serves per-arch. RELEASING.md §7 rewritten: the six secrets, the environment setup (called out hard — GitHub creates a missing environment with NO protection rules, so skipping it makes the gate decorative), the new flow, and the trade-off the old §7 named when it described this as the road not taken: the signing identity now lives in the cloud. Revocation path and blast-radius limits documented, along with how to go back. Verified: actionlint (which shellchecks every run: block) clean on all three. The notes gate was extracted from the workflow and exercised — real v1.0.4 notes pass; a real generated draft fails on TODOs and scaffolding; TODOs-removed-but- scaffolding-left still fails; v1.0.4 notes presented as v1.0.5 fail as stale; and generate → fill → delete scaffolding passes. --- .github/workflows/prepare-release.yml | 123 +++++++++++++++ .github/workflows/release.yml | 207 ++++++++++++++++++++++---- .github/workflows/tag-on-merge.yml | 82 ++++++++++ docs/RELEASING.md | 117 ++++++++++----- 4 files changed, 464 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/tag-on-merge.yml diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..f47c546 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,123 @@ +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 }} + run: | + 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..6c6b87a 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,170 @@ 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. + security list-keychain -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..a98cc14 --- /dev/null +++ b/.github/workflows/tag-on-merge.yml @@ -0,0 +1,82 @@ +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 on the base branch, not the PR head — that is the state being released. + ref: ${{ github.event.pull_request.base.ref }} + 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 + if grep -qF 'TODO' RELEASE-NOTES.md; then + echo "::error file=RELEASE-NOTES.md::Unfilled TODO markers remain:" + grep -nF 'TODO' RELEASE-NOTES.md | sed 's/^/ /' + FAILED=1 + fi + if grep -qF 'EVERYTHING BELOW IS SCAFFOLDING' RELEASE-NOTES.md; then + echo "::error file=RELEASE-NOTES.md::The scaffolding block was not deleted." + FAILED=1 + fi + # The notes must describe THIS release. A stale file from the previous version would + # otherwise sail through both checks above. + if ! head -1 RELEASE-NOTES.md | grep -qF "v$VERSION"; then + echo "::error file=RELEASE-NOTES.md::First line does not name v$VERSION — the notes look stale:" + head -1 RELEASE-NOTES.md | sed 's/^/ /' + FAILED=1 + fi + [ "$FAILED" -eq 0 ] || exit 1 + echo "RELEASE-NOTES.md is complete and names v$VERSION." + + - name: Create and push the tag + run: | + if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + echo "::error::Tag v$VERSION already exists — refusing to move a released tag." + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Annotated: `git describe --tags` prefers annotated tags, and the message is what shows + # up in `git show v1.0.5`. + git tag -a "v$VERSION" -m "LevelCode v$VERSION" + git push origin "v$VERSION" + echo "::notice::Pushed v$VERSION — release.yml is now building, signing and notarizing." diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c643d7b..6db7540 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -172,12 +172,84 @@ Full contract, rollout order, and risks: **`docs/AUTO-UPDATE.md`**. | Gatekeeper still warns after notarizing | Forgot to **staple**, or stapled the app but not the dmg. | | Chat won't open / shortcut dead in a build | `levelcode.ai.focus` is `Ctrl+Cmd+I` (moved off the `Cmd+Alt+I` DevTools collision); the chat also auto-reveals until the first message is sent. | -## 7. CI build — hybrid model (`.github/workflows/release.yml`) +## 7. CI release — automated, with one human gate (`.github/workflows/`) -**CI builds both arches; you sign locally.** Your Developer ID cert never touches GitHub. CI exists to -solve the awkward part — building the **Intel (x64)** dmg, which you can't easily do on an Apple-silicon -Mac — by building each arch on its **native** runner (`macos-14` = arm64, `macos-15-intel` = x64). It -produces **unsigned** `.app` bundles; you do the fast, sensitive sign + notarize + staple on your machine. +**CI does everything; you approve the last step.** Three workflows, in order: + +| Workflow | Trigger | What it does | +| --- | --- | --- | +| `prepare-release.yml` | you, manually | Drafts `RELEASE-NOTES.md` and opens a `release/vX.Y.Z` PR | +| `tag-on-merge.yml` | that PR merging | Refuses unfinished notes, then pushes the tag | +| `release.yml` | the tag | Builds both arches, signs, notarizes, drafts the release, **waits for you**, publishes | + +There is no version file to bump. The release version comes from `git describe --tags` at build time +(`scripts/build-macos.sh`), so **the tag is the version**. + +### One-time setup + +Six repository secrets — *Settings → Secrets and variables → Actions*: + +| Secret | Where it comes from | +| --- | --- | +| `APPLE_CERT_P12_BASE64` | Keychain Access → export the *Developer ID Application* cert **with its private key** as `.p12` → `base64 -i cert.p12 \| pbcopy` | +| `APPLE_CERT_PASSWORD` | the password you set on that export | +| `APPLE_SIGNING_IDENTITY` | `security find-identity -v -p codesigning` — the full `Developer ID Application: NAME (TEAMID)` string | +| `APPLE_ID` | the Apple ID owning the Developer Program membership | +| `APPLE_TEAM_ID` | the `(TEAMID)` from that identity | +| `APPLE_APP_SPECIFIC_PASSWORD` | appleid.apple.com → Sign-In and Security → App-Specific Passwords | + +> ⚠️ **And the gate itself** — *Settings → Environments → New environment → `release`* → tick +> **Required reviewers** and add yourself. **This is not optional.** GitHub creates a missing +> environment implicitly, with no protection rules, so without this the publish job runs +> straight through and the approval gate is decorative. Verify by opening the environment and +> confirming a reviewer is listed. + +### Cutting a release + +```sh +# 1. Draft the notes. Actions → "Prepare release" → Run workflow → version: 1.0.5 +# → opens PR "release: v1.0.5" with every FACT filled in and TODO markers where prose is needed. + +# 2. Write the prose in that PR: replace the TODOs, check the "excluded as internal" list at the +# bottom for anything user-visible, delete the scaffolding block. Merge it. +# → tag-on-merge.yml refuses to tag while a TODO or the scaffolding survives, or if the first +# line doesn't name this version. Then it pushes v1.0.5. + +# 3. Wait (~30–60 min/arch). release.yml builds both arches on native runners, signs with the +# Developer ID cert, notarizes with Apple, staples, verifies with spctl, and creates a DRAFT +# release carrying all four assets and RELEASE-NOTES.md as the body. + +# 4. Verify on a real Mac — §3. Download the dmg from the draft: +gh release download v1.0.5 --pattern 'LevelCode-arm64.dmg' + +# 5. Approve. Actions → the running "Publish (requires approval)" job → Review deployments → +# Approve. It flips draft=false and then polls the update feed to confirm it went live. +``` + +### Why the pause is where it is + +Steps 1–3 are all reversible: delete a branch, delete a tag, delete a draft. Step 5 is not. +Publishing **is** deploying — §5 — and the rollback pin cannot un-update anyone who already took +the release. So the automation runs right up to that line and stops, which is a better place for +your attention than remembering the `gh release upload` argument order. + +### The trade-off, stated plainly + +Earlier revisions of this doc described the hybrid model — CI builds unsigned, you sign locally — +and noted its one real virtue: *"puts your signing identity in the cloud — the hybrid flow above +deliberately doesn't."* That is now the cost we have accepted. The `.p12` and its password live in +GitHub secrets, and anyone who can push a workflow to this repo can, in principle, use them. +What limits the blast radius: + +- The cert is imported into a **temporary keychain in `RUNNER_TEMP`**, unlocked for that job only, + and deleted in an `if: always()` step. +- Secrets are **not exposed to workflows triggered from forks**, so a fork PR cannot reach them. +- Revocation is real and quick: revoke the cert in the Apple Developer portal and re-issue. Do that + the moment a repo admin leaves, or if a workflow file is ever changed by someone unexpected. + +If you'd rather not hold that risk, the hybrid model still works — remove the *Import the Developer +ID certificate* and *Sign, notarize…* steps from `release.yml`, upload the unsigned zips instead, +and sign locally with `NOTARY_PROFILE` as before. > **Intel runner note.** `macos-13` (the old x64 runner) was retired 2025-12-04, so we build x64 on > `macos-15-intel` — GitHub's last native x86_64 image. It's a premium/large runner (bills ~2× minutes) @@ -185,38 +257,11 @@ produces **unsigned** `.app` bundles; you do the fast, sensitive sign + notarize > arm64 runner (set `VSCODE_ARCH=x64`/`npm_config_arch=x64`, rebuild native modules for x64) rather than > build natively — a `scripts/build-macos.sh` + `scripts/bootstrap.sh` change, not just a runner swap. -The whole release becomes: - -```sh -# 1. Kick off CI (builds both arches, ~30–60 min/arch; free on public repos, 10× minutes while private) -git tag v0.1.0 && git push --tags -# → workflow builds → creates a DRAFT release with UNSIGNED-LevelCode-.app.zip attached - -# 2. Sign + notarize LOCALLY (needs the one-time setup from §1) -gh release download v0.1.0 --pattern 'UNSIGNED-*.app.zip' -for A in arm64 x64; do - rm -rf "VSCode-darwin-$A" && ditto -x -k "UNSIGNED-LevelCode-$A.app.zip" "VSCode-darwin-$A" - CODESIGN_IDENTITY="Developer ID Application: SERGII DEMIANCHUK (AJ27Y4Z2HS)" \ - NOTARY_PROFILE=levelcode-notary ./scripts/make-dmg.sh "$A" # → LevelCode-$A.dmg + LevelCode-$A.app.zip -done - -# 3. Verify (§3), then attach the dmgs AND the update zips, drop the unsigned zips, and publish. -# The .app.zip.sha256 files stay local on purpose — the feed uses GitHub's own asset digest (§5). -gh release upload v0.1.0 LevelCode-arm64.dmg LevelCode-x64.dmg \ - LevelCode-arm64.app.zip LevelCode-x64.app.zip -# `gh release delete-asset` takes ONE asset per call — drop each unsigned zip separately (`-y` skips the prompt). -gh release delete-asset v0.1.0 UNSIGNED-LevelCode-arm64.app.zip -y -gh release delete-asset v0.1.0 UNSIGNED-LevelCode-x64.app.zip -y -gh release edit v0.1.0 --draft=false --notes-file RELEASE-NOTES.md -``` - Notes: -- **No secrets required** — the workflow is credential-free by design (that's the whole point of hybrid). - The dmg names (`LevelCode-arm64.dmg` / `LevelCode-x64.dmg`) are exactly what the download funnel at `levelcode.ai/download/` expects — don't rename them. - `releases/latest` only resolves once this is a **published, non-prerelease** release with both dmgs. -- **Fully-automated alternative** (signing in CI) if you ever want zero local steps: base64 the `.p12` - Developer ID export + store it and an App Store Connect API key as secrets behind a *protected - Environment*, import into a temp keychain at job start, and run `make-dmg.sh` with `CODESIGN_IDENTITY` - set. Standard (VSCodium does this) but puts your signing identity in the cloud — the hybrid flow above - deliberately doesn't. +- The draft job **fails if any of the four assets is missing**, rather than publishing a half release — + losing just the x64 job would otherwise strand every Intel user, silently, since the feed is per-arch. +- The `.app.zip.sha256` files `make-dmg.sh` writes are still local-only: the feed reads GitHub's own + asset `digest` (§5), never a sidecar. From 4e4175b29e0ec1130b7bffd104e783a120a28c99 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 14 Aug 2026 13:32:43 -0400 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20address=20PR=20#63=20review=20?= =?UTF-8?q?=E2=80=94=20tag=20the=20merge=20commit,=20narrow=20the=20TODO?= =?UTF-8?q?=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Tag the merge commit, by SHA.** `tag-on-merge.yml` checked out `base.ref`, which resolves to whatever develop points at when the job starts — so anything merged in the window after the release PR got swept into the tag. Since the build stamps its version from `git describe --tags`, that ships code the release notes do not describe, silently. Now checks out `pull_request.merge_commit_sha`. **Narrow the TODO gate to the generator's marker.** It grepped for the bare word `TODO`, so prose legitimately containing it — describing a known gap, quoting a code comment — would block a finished release with no fix available except rewording. It now matches ` marker fail actionlint clean. --- .github/workflows/prepare-release.yml | 11 +++++++++++ .github/workflows/release.yml | 7 +++++-- .github/workflows/tag-on-merge.yml | 19 +++++++++++++++---- docs/RELEASING.md | 4 +++- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index f47c546..de76160 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -53,7 +53,18 @@ jobs: # 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c6b87a..bf0002c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,8 +159,11 @@ jobs: # 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. - security list-keychain -d user -s "$KEYCHAIN" login.keychain-db + # 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" diff --git a/.github/workflows/tag-on-merge.yml b/.github/workflows/tag-on-merge.yml index a98cc14..9f3570a 100644 --- a/.github/workflows/tag-on-merge.yml +++ b/.github/workflows/tag-on-merge.yml @@ -24,8 +24,14 @@ jobs: steps: - uses: actions/checkout@v7 with: - # The merge commit on the base branch, not the PR head — that is the state being released. - ref: ${{ github.event.pull_request.base.ref }} + # 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 @@ -48,9 +54,14 @@ jobs: # deploying here (docs/RELEASING.md §5) — there is no second chance to notice. run: | FAILED=0 - if grep -qF 'TODO' RELEASE-NOTES.md; then + # Match the generator's exact marker, `