Skip to content
Merged
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
68 changes: 51 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,28 @@ on:
options:
- "false"
- "true"
release_tag:
description: "Beta release tag to create when publish_release is true"
channel:
description: "prerelease (a beta; never latest) or stable (becomes releases/latest). Must match package.json: stable needs a version without -beta etc."
required: true
default: "v0.0.2-beta.1"
default: "prerelease"
type: choice
options:
- "prerelease"
- "stable"
# WHY these are optional and default to empty (Stage 8 of
# docs/decomposition/release-readiness.md): they used to default to
# v0.0.2-beta.1, the release that already exists, so every later
# dispatch had to override both or fail. Empty now means "derived from
# package.json" (scripts/release/identity.mjs), which is the only tag
# the check accepts anyway.
release_tag:
description: "Release tag; empty = v<package.json version>"
required: false
default: ""
release_name:
description: "Release name to create when publish_release is true"
required: true
default: "Agent Code 0.0.2 Beta 1"
description: "Release name; empty = Agent Code <version>"
required: false
default: ""

concurrency:
group: release-macos
Expand All @@ -27,6 +41,13 @@ concurrency:
jobs:
validate-release:
runs-on: ubuntu-latest
# The release identity, decided once before any build and handed to the
# job that publishes (package-macos).
outputs:
tag: ${{ steps.identity.outputs.tag }}
name: ${{ steps.identity.outputs.name }}
prerelease: ${{ steps.identity.outputs.prerelease }}
make_latest: ${{ steps.identity.outputs.make_latest }}
steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down Expand Up @@ -55,17 +76,17 @@ jobs:
exit 1
fi

# The rules (tag = v<version>; the stable channel needs a stable
# version and the prerelease channel a prerelease one) live in a script
# with system tests: testing/system/release/identity.test.ts.
- name: Validate release identity
id: identity
if: ${{ github.event.inputs.publish_release == 'true' }}
env:
CHANNEL: ${{ github.event.inputs.channel }}
RELEASE_TAG: ${{ github.event.inputs.release_tag }}
run: |
version="$(node -p "require('./package.json').version")"
expected="v${version}"
if [ "$RELEASE_TAG" != "$expected" ]; then
printf 'Release tag must match package.json exactly: expected %s, got %s\n' "$expected" "$RELEASE_TAG"
exit 1
fi
RELEASE_NAME: ${{ github.event.inputs.release_name }}
run: node scripts/release/identity.mjs

# DRIFT WARNING: build-app and package-macos below are mirrored verbatim in
# .github/workflows/reusable-app-build-macos.yml, which the nightly
Expand Down Expand Up @@ -189,7 +210,9 @@ jobs:
if-no-files-found: error

package-macos:
needs: build-app
# validate-release is listed so its identity outputs are in `needs` here;
# build-app already waits for it.
needs: [validate-release, build-app]
runs-on: macos-latest
permissions:
contents: write
Expand Down Expand Up @@ -351,9 +374,20 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.publish_release == 'true' }}
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64
with:
tag_name: ${{ github.event.inputs.release_tag }}
name: ${{ github.event.inputs.release_name }}
prerelease: true
tag_name: ${{ needs.validate-release.outputs.tag }}
# The tag must name the commit that was BUILT (#1035 review). A
# stable release is created as a draft and published after the
# uploads, and GitHub creates the tag only then. Without this it tags
# the default branch's head at that moment, which can be a commit
# merged during the ~26-minute build, or main when the release was
# dispatched from another ref.
target_commitish: ${{ github.sha }}
name: ${{ needs.validate-release.outputs.name }}
# Hardcoded `true` until Stage 8, so no stable release could be
# made and releases/latest 404'd. make_latest is explicit either
# way: a beta must never become the fallback latest.
prerelease: ${{ needs.validate-release.outputs.prerelease == 'true' }}
make_latest: ${{ needs.validate-release.outputs.make_latest }}
files: |
release/*.dmg
release/*.zip
Expand Down
37 changes: 37 additions & 0 deletions docs/superpowers/plans/2026-09-19-release-stable-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Release workflow: a stable channel

Stage 8 of `docs/decomposition/release-readiness.md`, first item.

## A: what exists

`.github/workflows/release.yml` builds, signs, notarizes and verifies the macOS app, then creates a GitHub Release. The release step hardcodes `prerelease: true` and sets no `make_latest`. The dispatch inputs default to `v0.0.2-beta.1` / "Agent Code 0.0.2 Beta 1", the release that already exists.

The only identity check is inline bash in `validate-release`, which nothing can test: "tag must equal `v` + package.json version".

On GitHub, every existing release is a prerelease and none is marked latest (`gh release list`, 2026-09-19). So `releases/latest` 404s, and so does the landing page's stable download button.

## D: the end state

- One dispatch input, `channel`, chooses `prerelease` (the default, what every past release was) or `stable`.
- A stable release is created with `prerelease: false` and `make_latest: true`, so `releases/latest` resolves to it.
- A prerelease is created exactly as before: `prerelease: true`, never latest.
- The tag and name default from package.json, so the next release is not dispatched with last release's stale defaults.
- The rules that keep a channel honest are enforced before a 26-minute build starts:
- a stable channel needs a version without a prerelease suffix;
- a prerelease channel needs one;
- the tag must equal `v` + version.

## Stages

1. **Identity rules as a script.**
- **Produces:** `scripts/release/identity.mjs`. It reads the channel and the optional tag and name inputs, plus the version in package.json, and it writes `tag`, `name`, `prerelease` and `make_latest` to `GITHUB_OUTPUT`, or exits 1 with the reason.
- **Verified by:** `testing/system/release/identity.test.ts`. It runs the script as a real process, the same way the workflow does, on the real recorded versions (`0.0.2-beta.1`, the published beta) and the planned stable `0.1.0`. It is written before the script.
- **Why separate:** the rules decide whether a signed build becomes `latest` for every user. Inline YAML is how nightly.mjs's three defects went unnoticed (#1012).
2. **The workflow calls it.**
- **Produces:** `validate-release` exposes the script's outputs as job outputs. `package-macos` creates the release from them.
- **Verified by:** YAML review, and an `actionlint`-style read that every `needs.*.outputs` reference exists. The first real stable dispatch is Stage 8's last step.

## Unknowns

- Whether the owner wants `0.1.0` (the ledger default) or another number. The script does not care; the bump is a separate change.
- `softprops/action-gh-release` pinned at `efb35369`: `make_latest` accepts the strings `"true"`, `"false"` and `"legacy"`. The script emits strings.
80 changes: 80 additions & 0 deletions scripts/release/identity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env node
// Which tag, name and release flags a manual release dispatch publishes
// (.github/workflows/release.yml, validate-release job).
//
// WHY a script and not inline workflow bash: these rules decide whether a
// signed build becomes `releases/latest`, which the landing page's download
// button and every "latest" link resolve to. The nightly logic moved out of
// YAML for the same reason after the #1012 review found three defects there
// that nothing could have caught. testing/system/release/identity.test.ts
// runs this file as a real process with the workflow's env.
//
// Inputs (env): CHANNEL ('prerelease' | 'stable', default 'prerelease'),
// RELEASE_TAG and RELEASE_NAME (both optional; derived from package.json when
// empty), GITHUB_OUTPUT. Writes tag, name, prerelease and make_latest, or
// exits 1 with the reason BEFORE the 26-minute build starts.
//
// Only Node built-ins, so the Ubuntu job needs no `npm ci`.

import { appendFileSync, readFileSync } from 'node:fs'

const CHANNELS = ['prerelease', 'stable']

function fail(message) {
process.stderr.write(`${message}\n`)
process.exit(1)
}

const channel = process.env.CHANNEL || 'prerelease'
if (!CHANNELS.includes(channel)) fail(`Unknown release channel ${JSON.stringify(channel)}; use one of: ${CHANNELS.join(', ')}.`)

const version = JSON.parse(readFileSync('package.json', 'utf8')).version
if (typeof version !== 'string' || version.length === 0) fail('package.json has no version.')

// A semver prerelease is anything after a '-' in the version core
// (0.0.2-beta.1). The channel must agree with it both ways:
// - a stable release of a prerelease version would make a beta `latest`;
// - a prerelease of a stable version would take the stable tag (v0.1.0), and
// the real stable release could then never be created under it.
// Build metadata (`+…`) is not part of precedence and may itself contain a
// hyphen (0.1.0+build-5), so only the part before '+' decides.
const isPrereleaseVersion = version.split('+')[0].includes('-')
if (channel === 'stable' && isPrereleaseVersion) {
fail(`A stable release needs a stable version, but package.json is ${version}. Bump it (for example to ${version.split('-')[0]}) first.`)
}
if (channel === 'prerelease' && !isPrereleaseVersion) {
// Suggest a prerelease of the NEXT patch: ${version}-beta.1 would sort
// BEFORE ${version} and read as older than the stable it follows.
const [major, minor, patch] = version.split('+')[0].split('.').map(Number)
const next = [major, minor, Number.isFinite(patch) ? patch + 1 : 0].join('.')
fail(`A prerelease needs a prerelease version, but package.json is ${version}. Use the stable channel, or bump to a prerelease of the next version (for example ${next}-beta.1).`)
}

const expectedTag = `v${version}`
const tag = process.env.RELEASE_TAG || expectedTag
if (tag !== expectedTag) fail(`Release tag must match package.json exactly: expected ${expectedTag}, got ${tag}.`)
const name = process.env.RELEASE_NAME || `Agent Code ${version}`
// GITHUB_OUTPUT is `key=value` lines, and a repeated key keeps its LAST
// value, so a newline in a free-text input could append `tag=…` and replace
// the validated tag (#1035 review). Refuse line breaks outright.
for (const [label, value] of [['release_tag', tag], ['release_name', name]]) {
if (/[\r\n]/u.test(value)) fail(`${label} must be a single line.`)
}

const stable = channel === 'stable'
const outputs = {
tag,
name,
prerelease: stable ? 'false' : 'true',
// Strings, because softprops/action-gh-release takes "true", "false" or
// "legacy". GitHub refuses to make a prerelease latest anyway; saying
// "false" keeps a beta from ever being the fallback latest.
// KNOWN LIMIT (#1035 review): re-dispatching an OLDER stable (say v0.1.0
// after v0.2.0 shipped) also sends true and takes `latest` back. Nothing
// here can see the published releases without a token; do not re-publish an
// old stable, or set it back from the Releases page afterwards.
make_latest: stable ? 'true' : 'false',
}
const target = process.env.GITHUB_OUTPUT
if (!target) fail('GITHUB_OUTPUT is not set.')
appendFileSync(target, Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join(''))
104 changes: 104 additions & 0 deletions testing/system/release/identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'

// System tests for scripts/release/identity.mjs: which tag, name and release
// flags a manual release dispatch publishes (release.yml, validate-release).
//
// WHY these rules are tested at all: they decide whether a signed build
// becomes `releases/latest`, the file the landing page's download button and
// every "latest" link resolve to. The previous inline bash only compared the
// tag with package.json, and the release step hardcoded `prerelease: true`,
// so a stable release could not be made at all (Stage 8 of
// docs/decomposition/release-readiness.md).
//
// The script runs as a REAL child process with the env and GITHUB_OUTPUT file
// the workflow gives it. The versions are real: `0.0.2-beta.1` is the version
// of the published beta (gh release list, 2026-09-19) and of package.json
// today; `0.1.0` is the stable number the ledger defaults to.

const script = resolve(__dirname, '../../../scripts/release/identity.mjs')
const dirs: string[] = []
afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) })

function run(version: string, env: Record<string, string>) {
const cwd = mkdtempSync(join(tmpdir(), 'release-identity-'))
dirs.push(cwd)
writeFileSync(join(cwd, 'package.json'), JSON.stringify({ name: 'agent-code', version }))
const output = join(cwd, 'github-output')
writeFileSync(output, '')
const result = spawnSync(process.execPath, [script], {
cwd, encoding: 'utf8',
env: { PATH: process.env.PATH ?? '', GITHUB_OUTPUT: output, ...env },
})
const outputs = Object.fromEntries(readFileSync(output, 'utf8').split('\n').filter(Boolean).map(line => {
const at = line.indexOf('=')
return [line.slice(0, at), line.slice(at + 1)]
}))
return { status: result.status, stderr: result.stderr, outputs }
}

describe('release identity', () => {
it('a stable release of 0.1.0 is v0.1.0, not a prerelease, and becomes latest', () => {
const result = run('0.1.0', { CHANNEL: 'stable' })
expect(result.status).toBe(0)
expect(result.outputs).toEqual({ tag: 'v0.1.0', name: 'Agent Code 0.1.0', prerelease: 'false', make_latest: 'true' })
})

it('a prerelease is published exactly as the betas were: prerelease, never latest', () => {
const result = run('0.0.2-beta.1', { CHANNEL: 'prerelease' })
expect(result.status).toBe(0)
expect(result.outputs).toMatchObject({ tag: 'v0.0.2-beta.1', prerelease: 'true', make_latest: 'false' })
})

it('defaults to the prerelease channel when none is given', () => {
// An old dispatch form (or a CLI call without the input) must never
// promote a build to latest by accident.
expect(run('0.0.2-beta.1', {}).outputs).toMatchObject({ prerelease: 'true', make_latest: 'false' })
})

it('refuses a stable release of a prerelease version, before any build', () => {
const result = run('0.0.2-beta.1', { CHANNEL: 'stable' })
expect(result.status).toBe(1)
expect(result.stderr).toMatch(/stable.*0\.0\.2-beta\.1/)
expect(result.outputs).toEqual({})
})

it('refuses a prerelease of a stable version, which would take the stable tag', () => {
const result = run('0.1.0', { CHANNEL: 'prerelease' })
expect(result.status).toBe(1)
expect(result.stderr).toMatch(/prerelease.*0\.1\.0/)
})

it('refuses a tag that does not match package.json', () => {
// The check the old inline bash made; kept.
const result = run('0.1.0', { CHANNEL: 'stable', RELEASE_TAG: 'v0.0.2-beta.1' })
expect(result.status).toBe(1)
expect(result.stderr).toMatch(/v0\.1\.0.*v0\.0\.2-beta\.1/)
})

it('accepts an explicit matching tag and a custom name', () => {
const result = run('0.1.0', { CHANNEL: 'stable', RELEASE_TAG: 'v0.1.0', RELEASE_NAME: 'Agent Code 0.1' })
expect(result.outputs).toMatchObject({ tag: 'v0.1.0', name: 'Agent Code 0.1' })
})

it('reads only the version core: build metadata with a hyphen is still stable', () => {
expect(run('0.1.0+build-5', { CHANNEL: 'stable' }).outputs).toMatchObject({ prerelease: 'false', make_latest: 'true' })
})

it('refuses a name or tag with a line break, which could override the validated tag in GITHUB_OUTPUT', () => {
const result = run('0.0.2-beta.1', { CHANNEL: 'prerelease', RELEASE_NAME: 'Agent Code\ntag=v9.9.9' })
expect(result.status).toBe(1)
expect(result.outputs).toEqual({})
})

it('suggests a prerelease of the NEXT version, which sorts after the stable', () => {
expect(run('0.1.0', { CHANNEL: 'prerelease' }).stderr).toMatch(/0\.1\.1-beta\.1/)
})

it('refuses an unknown channel instead of guessing', () => {
expect(run('0.1.0', { CHANNEL: 'latest' }).status).toBe(1)
})
})
Loading