Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
name: Release

# Source release of NMSh: annotated tag + GitHub Release with the source archive and checksums, then a Homebrew
# formula PR. Procedure and failure recovery: docs/development/release-workflow.md. The website sync PR and the manual
# "Publish NMSh website" workflow are separate and are not triggered from here.

on:
workflow_dispatch:
inputs:
version:
description: Version to release (X.Y.Z, without v)
type: string
required: true
commit:
description: Full commit SHA (empty = the dispatched ref's head). Publishing requires master's head.
type: string
required: false
mode:
description: dry-run checks and packages only; publish tags and releases
type: choice
options: [dry-run, publish]
default: dry-run
confirmation:
description: 'Publishing only: type "publish vX.Y.Z"'
type: string
required: false
pull_request:
paths:
- scripts/release/**
- .github/workflows/release.yml
- tests/release*.test.ts

permissions: {}

# An explicit bash shell runs with -eo pipefail: a failing gate piped into tee must still fail its step.
defaults:
run:
shell: bash

concurrency:
# Pull-request dry runs get their own group so they never displace or wait behind a release dispatch.
group: ${{ github.event_name == 'pull_request' && format('nmsh-release-pr-{0}', github.ref) || 'nmsh-release' }}
cancel-in-progress: false

jobs:
prepare:
if: github.repository == 'raiseCatError/notMyShell'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
contents: read
actions: read
checks: read
outputs:
version: ${{ steps.target.outputs.version }}
commit: ${{ steps.target.outputs.commit }}
archive_sha256: ${{ steps.package.outputs.archive_sha256 }}
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_COMMIT: ${{ inputs.commit }}
INPUT_MODE: ${{ inputs.mode }}
INPUT_CONFIRMATION: ${{ inputs.confirmation }}
ARCHIVE_SHA256: ${{ needs.prepare.outputs.archive_sha256 }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
ref: ${{ inputs.commit || github.sha }}
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 22.22.0
cache: npm
- run: npm ci
- name: Resolve the target
id: target
run: |
# Pull requests (and an empty input) only ever dry-run; the version then comes from package.json.
VERSION="${INPUT_VERSION:-$(node -p "require('./package.json').version")}"
MODE="${INPUT_MODE:-dry-run}"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version must be X.Y.Z"; exit 1; }
[ "$GITHUB_EVENT_NAME" = workflow_dispatch ] || MODE=dry-run
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
- name: Require CI on the exact commit and master's head (publish only)
if: steps.target.outputs.mode == 'publish'
run: node scripts/release/check-ci.mjs --commit "${{ steps.target.outputs.commit }}" --repo "$GITHUB_REPOSITORY"
- name: Prepare (checks, provenance, release notes)
env:
VERSION: ${{ steps.target.outputs.version }}
COMMIT: ${{ steps.target.outputs.commit }}
MODE: ${{ steps.target.outputs.mode }}
run: |
MASTER_HEAD=""
if [ "$MODE" = publish ]; then MASTER_HEAD="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/master" --jq .object.sha)"; fi
node scripts/release/prepare.mjs --version "$VERSION" --commit "$COMMIT" --mode "$MODE" --repo "$GITHUB_REPOSITORY" \
--master-head "$MASTER_HEAD" --confirmation "$INPUT_CONFIRMATION" --out "$RUNNER_TEMP/release" | tee "$RUNNER_TEMP/prepare.txt"
{ echo '### Release preparation'; echo '```'; cat "$RUNNER_TEMP/prepare.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Package the source archive
id: package
env:
VERSION: ${{ steps.target.outputs.version }}
COMMIT: ${{ steps.target.outputs.commit }}
run: |
node scripts/release/package.mjs --version "$VERSION" --commit "$COMMIT" --out "$RUNNER_TEMP/release"
echo "archive_sha256=$(sha256sum "$RUNNER_TEMP/release/nmsh-$VERSION-source.tar.gz" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Verify the archive installs like the Homebrew formula
env:
VERSION: ${{ steps.target.outputs.version }}
run: node scripts/release/verify-artifact.mjs --version "$VERSION" --dir "$RUNNER_TEMP/release" --work "$RUNNER_TEMP/verify"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with:
name: nmsh-release
path: ${{ runner.temp }}/release
if-no-files-found: error
retention-days: 14

publish:
needs: prepare
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' && github.ref == 'refs/heads/master'
runs-on: ubuntu-24.04
timeout-minutes: 20
environment:
name: nmsh-production
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
COMMIT: ${{ needs.prepare.outputs.commit }}
INPUT_CONFIRMATION: ${{ inputs.confirmation }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 22.22.0
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
name: nmsh-release
path: ${{ runner.temp }}/release
- name: Tag, draft release, upload, verify, publish
run: node scripts/release/publish.mjs --version "$VERSION" --commit "$COMMIT" --dir "$RUNNER_TEMP/release" --confirmation "$INPUT_CONFIRMATION" --expected-sha256 "$ARCHIVE_SHA256"

homebrew:
needs: [prepare, publish]
if: needs.publish.result == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 15
environment:
name: nmsh-production
permissions:
contents: read
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 22.22.0
- name: Build the formula from GitHub's tag archive
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p "$RUNNER_TEMP/brew"
curl -fsSL --retry 5 --retry-all-errors -o "$RUNNER_TEMP/brew/tag.tar.gz" "https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/v$VERSION.tar.gz"
SHA="$(sha256sum "$RUNNER_TEMP/brew/tag.tar.gz" | cut -d' ' -f1)"
gh api -H 'Accept: application/vnd.github.raw' repos/raiseCatError/homebrew-tap/contents/Formula/nmsh.rb > "$RUNNER_TEMP/brew/current.rb"
node scripts/release/homebrew.mjs --version "$VERSION" --sha256 "$SHA" --formula "$RUNNER_TEMP/brew/current.rb" --out "$RUNNER_TEMP/brew/nmsh.rb"
echo "TAG_SHA=$SHA" >> "$GITHUB_ENV"
- name: Open the tap pull request (only when HOMEBREW_TAP_TOKEN exists)
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -n "$HOMEBREW_TAP_TOKEN" ]; then
GH_TOKEN="$HOMEBREW_TAP_TOKEN" node scripts/release/homebrew.mjs --version "$VERSION" --sha256 "$TAG_SHA" \
--formula "$RUNNER_TEMP/brew/current.rb" --out "$RUNNER_TEMP/brew/nmsh.rb" --open-pr | tee -a "$GITHUB_STEP_SUMMARY"
else
{
echo "### Homebrew tap not updated"
echo "No HOMEBREW_TAP_TOKEN secret exists. Download the \`nmsh-formula\` artifact, put it at Formula/nmsh.rb on branch \`nmsh-$VERSION\` of raiseCatError/homebrew-tap and open a PR titled \`nmsh $VERSION\`. url sha256: \`$TAG_SHA\`."
} >> "$GITHUB_STEP_SUMMARY"
fi
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with:
name: nmsh-formula
path: ${{ runner.temp }}/brew/nmsh.rb
if-no-files-found: error
retention-days: 30
107 changes: 107 additions & 0 deletions docs/development/release-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Release workflow

How a release is checked, packaged and published by `.github/workflows/release.yml` and `scripts/release/`. The
merge, tag and tap steps keep the shape of the [v0.22 runbook](v022-release-runbook.md); this workflow makes the
checkable parts repeatable. **Publishing needs the maintainer's go-ahead** and an approval of the `nmsh-production`
environment.

NMSh is released as source: an annotated tag `vX.Y.Z` on the `master` merge commit and a GitHub Release carrying the
source archive and checksums. `package.json` is `private` (no npm publish). The Homebrew tap formula builds from the
tag archive with `npm ci` and the node-pty native addon, so **no standalone or portable binaries are offered**.

## Pieces

| Script | What it does |
|---|---|
| `prepare.mjs` | Rules and checks: version consistency, refs, QA gate, duplicates, build, typecheck, `test:fast`, `npm audit --omit=dev`. Writes `provenance.json` and `release-notes.md`, prints a BLOCKERS list. |
| `package.mjs` | `git archive` of the exact commit into `nmsh-X.Y.Z-source.tar.gz` (prefix `notMyShell-X.Y.Z/`), then `SHA256SUMS`. The same commit gives the same bytes. |
| `verify-artifact.mjs` | Verifies `SHA256SUMS`, extracts into an isolated directory, mimics the formula (`npm ci`, `npm run build`, `npm prune --omit=dev`), runs `nmsh --version` and a node-pty spawn, all with a private `HOME`/`XDG_*`. |
| `check-ci.mjs` | Publish gate: `Verify (22.x)` and `Verify (26.x)` succeeded on the exact commit, plus a successful `workflow_dispatch` CI run (Fedora and the timing smokes run only on dispatch). |
| `publish.mjs` | Annotated tag at the commit, DRAFT release, assets, re-download and verify, then publish. |
| `homebrew.mjs` | Rewrites `url` and `sha256` only in the tap formula; optionally opens the tap PR. |
| `lib.mjs` | The pure rules; tested in `tests/releaseWorkflow.test.ts`. |

The physical QA gate is a machine-readable file, `docs/releases/vX.Y.Z-qa.json`:

```json
{"version": "0.24.0", "rows": [
{"id": "ghostty-first-run", "status": "pass"},
{"id": "kitty-graphics", "status": "waived", "reason": "no Kitty on the QA machine; maintainer accepted"}
]}
```

Every row must be `pass`, or `waived` with a reason. `fail`, `pending` or a missing file blocks. A `pending` row is only a warning for
`candidate`, not for `publish`. The release notes state the QA result and any waivers. The prose record stays in
`docs/testing/`.

## Modes

- **dry-run** (default; also what a pull request touching the release files runs): reports every blocker and
continues. Exit code 0 unless the script itself breaks. Nothing is created.
- **candidate**: like a dry run, but exits non-zero on hard blockers. An `Unreleased`/candidate changelog heading
and `pending` QA rows are warnings.
- **publish**: any blocker stops it. Needs the commit to be `master`'s head, the confirmation `publish vX.Y.Z`, a dated
changelog section, a complete QA file, passing checks and the CI gate.

## Run it

Locally (scratch on the T7; this only reads GitHub):

```sh
t7run node scripts/release/prepare.mjs --version X.Y.Z --mode dry-run --out "$T7_RUN_DIR/rel"
t7run node scripts/release/package.mjs --version X.Y.Z --out "$T7_RUN_DIR/rel"
t7run node scripts/release/verify-artifact.mjs --version X.Y.Z --dir "$T7_RUN_DIR/rel"
```

`prepare` needs a clean tree with `HEAD` at the commit. `--skip-checks` and `--offline` exist for dry runs only.

In GitHub: **Actions → Release → Run workflow** from `master`, with `version`, `commit` (empty = the ref's head;
publishing needs `master`'s head), `mode` and, to publish, `confirmation` = `publish vX.Y.Z`. The `prepare` job
checks, packages and verifies; the `publish` job (only for `mode=publish` on `master`) waits for the `nmsh-production`
approval, then re-checks duplicates, tags, drafts, uploads, re-downloads, verifies and publishes. The `homebrew` job
builds the formula from GitHub's tag archive and opens the tap PR `nmsh X.Y.Z` only if the `HOMEBREW_TAP_TOKEN`
secret exists; otherwise it uploads the `nmsh-formula` artifact and says what to do. It never merges and never
publishes bottles. The tap's own `tests.yml` runs on that PR, and the maintainer dispatches the tap's `publish.yml`.

Order for a release: finish the release PR into `master` (see the runbook), dispatch CI on its merge commit, run this
workflow as a dry run, fix blockers, then publish. The website sync PR and the manual **Publish NMSh website** run stay
separate and are not triggered here.

## Failure recovery

**CI fails or is missing.** `check-ci` names the missing check. Fix on the release branch, merge, dispatch CI on the
new `master` head and rerun. Nothing was created.

**Packaging or `prepare` fails.** Nothing public exists. Read `logs/` in the uploaded `nmsh-release` artifact (or the
local `--out` directory), fix, rerun. A `provenance.json` for another commit is refused by `package.mjs`; rerun
`prepare` for this commit.

**An asset cannot be verified.** If `verify-artifact` fails, it is before publishing: fix and rerun. If `publish`
reports the re-downloaded assets do not verify, the release is still a **draft**: nothing is public. Rerun the
workflow; it resumes the draft and uploads with `--clobber`. If it keeps failing, delete the draft in the GitHub UI
and rerun; keep the tag.

**The GitHub Release fails partway.** The tag may exist and a draft may be left. A tag that was pushed is **never
moved or deleted** (Homebrew, GitHub's archive and users may have it). Rerunning with the same version and commit
resumes: a tag at the same commit with no release or only a draft is reused. If the tag is at a different commit,
the workflow refuses; fix forward with the next patch version. Only when the tag points at the wrong commit and nothing
(release, tap, website) has consumed it may the maintainer delete it by hand, as in the runbook's rollback section.

**The Homebrew PR fails.** The release is already public. Rerun only the `homebrew` job, or use the `nmsh-formula`
artifact by hand. If branch `nmsh-X.Y.Z` already exists without a PR, open the PR from it (or delete the branch and
rerun). If the tap's `tests.yml` fails, fix the formula in the tap PR; `homebrew.mjs` only changes `url` and `sha256`.

**The release already exists.** `prepare` and `publish` refuse with `release-exists`. This is correct: never overwrite a
published release. To fix its text, `gh release edit`; to fix its contents, publish the next patch version.

**A local install fails after switching.** Source install: `npm ci && npm run build` in the checkout, then check
`nmsh --version`. Homebrew: `brew update && brew upgrade raiseCatError/tap/nmsh`, then `brew reinstall` if the
native addon is stale. To go back, use the runbook's rollback (`gh release edit vPREVIOUS --latest`, prerelease flag on
the bad one, revert the tap PR).

## Known limits

- A tarball build reports `build unknown`; it has no Git history. The tag archive Homebrew uses is GitHub's own and
is not byte-identical to the attached archive; its checksum is computed after tagging.
- `npm audit` findings are reported in `provenance.json` and the logs but do not block by themselves.
- The workflow's `uses:` are pinned to commit SHAs, matching `website-deploy.yml`.
21 changes: 21 additions & 0 deletions scripts/release/check-ci.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// Publish gate: the exact commit has successful Verify (22.x) / Verify (26.x) checks and a successful dispatched CI run.
// Usage: node scripts/release/check-ci.mjs --commit SHA [--repo owner/name] (reads GitHub with GH_TOKEN)
import {parseArgs} from 'node:util';
import {pathToFileURL} from 'node:url';
import {evaluateCiGate} from './lib.mjs';
import {gh} from './github.mjs';

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const {values} = parseArgs({options: {commit: {type: 'string'}, repo: {type: 'string', default: 'raiseCatError/notMyShell'}}});
if (!/^[0-9a-f]{40}$/u.test(values.commit ?? '')) throw new Error('--commit must be a 40-character SHA');
const checkRuns = gh(['api', '--paginate', '--slurp', `repos/${values.repo}/commits/${values.commit}/check-runs?per_page=100`]).flatMap(page => page.check_runs)
// Only GitHub Actions' own check runs count: another app with checks:write could post a look-alike name.
.filter(run => run.app?.slug === 'github-actions');
const dispatchRuns = gh(['api', '--paginate', '--slurp', `repos/${values.repo}/actions/workflows/ci.yml/runs?head_sha=${values.commit}&event=workflow_dispatch&per_page=100`]).flatMap(page => page.workflow_runs);
const blockers = evaluateCiGate({checkRuns, dispatchRuns});
if (blockers.length) { for (const item of blockers) console.error(`- [${item.code}] ${item.message}`); process.exit(1); }
console.log(`CI is green on ${values.commit}`);
} catch (error) { console.error(error.message); process.exit(2); }
}
32 changes: 32 additions & 0 deletions scripts/release/github.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Thin wrappers over the gh CLI. Tokens come from the environment (GH_TOKEN); nothing is ever printed from it.
import {execFileSync} from 'node:child_process';

export function gh(args, {input, json = true} = {}) {
const out = execFileSync('gh', args, {encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024});
return json ? (out.trim() === '' ? undefined : JSON.parse(out)) : out;
}

function notFound(error) {
return /HTTP 404|Not Found/iu.test(`${error.stderr ?? ''}${error.message ?? ''}`);
}

export function getMasterHead(repo) {
return gh(['api', `repos/${repo}/git/ref/heads/master`]).object.sha;
}

// {tag: {exists, commit} , release: {id, draft, tag_name, assets} | undefined}. Reads only.
export function getRemoteState(repo, tag) {
let tagState = {exists: false};
try {
let object = gh(['api', `repos/${repo}/git/ref/tags/${tag}`]).object;
while (object.type === 'tag') object = gh(['api', `repos/${repo}/git/tags/${object.sha}`]).object;
tagState = {exists: true, commit: object.sha};
} catch (error) { if (!notFound(error)) throw error; }
const releases = gh(['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`]).flat();
const release = releases.find(item => item.tag_name === tag);
return {tag: tagState, release};
}

export function ghBuffer(args) {
return execFileSync('gh', args, {stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 512 * 1024 * 1024});
}
Loading
Loading