Skip to content

chore(release): 0.3.0 (#119) #6

chore(release): 0.3.0 (#119)

chore(release): 0.3.0 (#119) #6

Workflow file for this run

name: Release
# Keep this workflow hand-curated. The prepare job produces the verified tarball,
# checksum, and metadata that downstream publish steps on GitHub Releases and
# npm should reuse instead of rebuilding from scratch.
on:
workflow_dispatch:
inputs:
tag:
description: Git tag to release (for example v0.1.0)
required: true
type: string
push:
tags:
- 'v*'
concurrency:
group: release-${{ github.workflow }}-${{ github.event.inputs.tag || github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
prepare-release:
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
release_tag: ${{ steps.release-metadata.outputs.release_tag }}
package_name: ${{ steps.release-metadata.outputs.package_name }}
package_version: ${{ steps.release-metadata.outputs.package_version }}
tarball_filename: ${{ steps.release-metadata.outputs.tarball_filename }}
checksum_filename: ${{ steps.release-metadata.outputs.checksum_filename }}
checksum_sha256: ${{ steps.release-metadata.outputs.checksum_sha256 }}
steps:
- name: Resolve release tag
id: release-tag
shell: bash
env:
WORKFLOW_DISPATCH_TAG: ${{ github.event.inputs.tag }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == 'workflow_dispatch' ]]; then
release_tag="${WORKFLOW_DISPATCH_TAG}"
else
release_tag="${GITHUB_REF_NAME}"
fi
if [[ -z "$release_tag" ]]; then
echo 'Release tag must not be empty.' >&2
exit 1
fi
if [[ "$release_tag" != v* ]]; then
echo "Release tag must start with v: $release_tag" >&2
exit 1
fi
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ steps.release-tag.outputs.release_tag }}
fetch-depth: 0
persist-credentials: false
- name: Fetch default branch for release ancestry checks
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
auth_header="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags origin \
"refs/heads/$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH"
- name: Validate release tag commit is on the default branch
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if ! git merge-base --is-ancestor HEAD "refs/remotes/origin/$DEFAULT_BRANCH"; then
echo "Release tag must reference a commit already merged into $DEFAULT_BRANCH." >&2
echo "Create the version-bump PR first, merge it, then tag the merged default-branch commit." >&2
exit 1
fi
- name: Set up mise
uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3
with:
cache: false
install_args: --locked
- name: Install CI dependencies
run: mise run bootstrap-ci
# bootstrap-ci intentionally skips browser downloads so jobs that do not
# need Chromium can stay on deterministic `npm ci`. This release workflow
# runs the full Linux quality bar, so install Chromium explicitly just as
# `.github/workflows/ci.yml` does.
- name: Install Playwright Chromium
run: npx playwright install chromium
# Keep the tag/package check strict so release assets always match the
# committed package metadata. The documented flow is: version bump on a
# PR branch, merge it, then tag the merged default-branch commit.
- name: Validate release tag matches package version
shell: bash
env:
RELEASE_TAG: ${{ steps.release-tag.outputs.release_tag }}
run: |
set -euo pipefail
package_version="$(node --input-type=module <<'EOF'
import { readFileSync } from 'node:fs';
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
process.stdout.write(packageJson.version);
EOF
)"
expected_tag="v${package_version}"
if [[ "$RELEASE_TAG" != "$expected_tag" ]]; then
echo "Release tag mismatch: expected $expected_tag from package.json, got $RELEASE_TAG" >&2
exit 1
fi
- name: Run release quality gates
run: mise run ci
- name: Pack verified release tarball
env:
RELEASE_DIR: ${{ runner.temp }}/release-assets
run: |
set -euo pipefail
mkdir -p "$RELEASE_DIR"
npm run pack:release -- \
--pack-destination "$RELEASE_DIR" \
--metadata-file "$RELEASE_DIR/package-metadata.json"
- name: Export release metadata
id: release-metadata
shell: bash
env:
METADATA_FILE: ${{ runner.temp }}/release-assets/package-metadata.json
RELEASE_TAG: ${{ steps.release-tag.outputs.release_tag }}
run: |
set -euo pipefail
node --input-type=module <<'EOF'
import assert from 'node:assert/strict';
import { appendFileSync, readFileSync } from 'node:fs';
const metadata = JSON.parse(readFileSync(process.env.METADATA_FILE, 'utf8'));
assert(metadata !== null && typeof metadata === 'object', 'metadata must be an object');
const outputs = {
release_tag: process.env.RELEASE_TAG,
package_name: metadata.packageName,
package_version: metadata.packageVersion,
tarball_filename: metadata.tarballFilename,
checksum_filename: metadata.checksumFilename,
checksum_sha256: metadata.checksumSha256,
};
for (const [key, value] of Object.entries(outputs)) {
assert(typeof value === 'string' && value.length > 0, `${key} must not be empty`);
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
}
EOF
- name: Upload release workflow artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-assets-${{ steps.release-metadata.outputs.release_tag }}
if-no-files-found: error
path: |
${{ runner.temp }}/release-assets/${{ steps.release-metadata.outputs.tarball_filename }}
${{ runner.temp }}/release-assets/${{ steps.release-metadata.outputs.checksum_filename }}
${{ runner.temp }}/release-assets/package-metadata.json
publish-github-release:
runs-on: ubuntu-latest
timeout-minutes: 20
needs: prepare-release
permissions:
contents: write
issues: read
pull-requests: read
steps:
- name: Download verified release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-assets-${{ needs.prepare-release.outputs.release_tag }}
path: ${{ runner.temp }}/release-assets
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.prepare-release.outputs.release_tag }}
fetch-depth: 0
persist-credentials: false
- name: Set up mise
uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3
with:
cache: false
install_args: --locked
- name: Generate Communique release notes
id: communique_notes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
COMMUNIQUE_MODEL: ${{ vars.COMMUNIQUE_MODEL }}
RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }}
COMMUNIQUE_NOTES_FILE: ${{ runner.temp }}/communique-notes.md
COMMUNIQUE_BODY_FILE: ${{ runner.temp }}/communique-body.md
run: |
set -euo pipefail
if [[ -z "${ANTHROPIC_API_KEY:-}" && -z "${OPENAI_API_KEY:-}" ]]; then
echo 'ANTHROPIC_API_KEY or OPENAI_API_KEY is required to generate GitHub Release notes with Communique.' >&2
exit 1
fi
if [[ -z "${ANTHROPIC_API_KEY:-}" && -z "${COMMUNIQUE_MODEL:-}" ]]; then
echo 'Set COMMUNIQUE_MODEL when using OPENAI_API_KEY so Communique can select an OpenAI-compatible model.' >&2
exit 1
fi
communique_args=()
if [[ -n "${COMMUNIQUE_MODEL:-}" ]]; then
communique_args+=(--model "$COMMUNIQUE_MODEL")
fi
communique generate "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--output "$COMMUNIQUE_NOTES_FILE" \
"${communique_args[@]}"
node --input-type=module <<'EOF'
import assert from 'node:assert/strict';
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
const releaseTag = process.env.RELEASE_TAG;
const notesPath = process.env.COMMUNIQUE_NOTES_FILE;
const bodyPath = process.env.COMMUNIQUE_BODY_FILE;
assert(typeof releaseTag === 'string' && releaseTag.length > 0, 'RELEASE_TAG must not be empty');
assert(typeof notesPath === 'string' && notesPath.length > 0, 'COMMUNIQUE_NOTES_FILE must not be empty');
assert(typeof bodyPath === 'string' && bodyPath.length > 0, 'COMMUNIQUE_BODY_FILE must not be empty');
const notes = readFileSync(notesPath, 'utf8').trimEnd();
assert(notes.length > 0, 'Communique notes must not be empty');
const lines = notes.split(/\r?\n/);
let releaseTitle = releaseTag;
let releaseBody = notes;
if (lines[0]?.startsWith('# ')) {
releaseTitle = lines[0].slice(2).trim();
releaseBody = lines.slice(1).join('\n').trimStart();
}
assert(releaseTitle.length > 0, 'Communique release title must not be empty');
assert(!releaseTitle.includes('\n'), 'Communique release title must be one line');
writeFileSync(bodyPath, `${releaseBody.trimEnd()}\n`);
appendFileSync(process.env.GITHUB_OUTPUT, `release_title=${releaseTitle}\n`);
EOF
- name: Write release notes
shell: bash
env:
RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }}
PACKAGE_NAME: ${{ needs.prepare-release.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.prepare-release.outputs.package_version }}
TARBALL_FILENAME: ${{ needs.prepare-release.outputs.tarball_filename }}
CHECKSUM_FILENAME: ${{ needs.prepare-release.outputs.checksum_filename }}
CHECKSUM_SHA256: ${{ needs.prepare-release.outputs.checksum_sha256 }}
COMMUNIQUE_BODY_FILE: ${{ runner.temp }}/communique-body.md
RELEASE_NOTES_FILE: ${{ runner.temp }}/release-notes.md
run: |
set -euo pipefail
node --input-type=module <<'EOF'
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
const communiqueBodyPath = process.env.COMMUNIQUE_BODY_FILE;
const releaseNotesPath = process.env.RELEASE_NOTES_FILE;
const releaseTag = process.env.RELEASE_TAG;
const packageName = process.env.PACKAGE_NAME;
const packageVersion = process.env.PACKAGE_VERSION;
const tarballFilename = process.env.TARBALL_FILENAME;
const checksumFilename = process.env.CHECKSUM_FILENAME;
const checksumSha256 = process.env.CHECKSUM_SHA256;
const githubServerUrl = process.env.GITHUB_SERVER_URL;
const githubRepository = process.env.GITHUB_REPOSITORY;
assert(typeof communiqueBodyPath === 'string' && communiqueBodyPath.length > 0, 'COMMUNIQUE_BODY_FILE must not be empty');
assert(typeof releaseNotesPath === 'string' && releaseNotesPath.length > 0, 'RELEASE_NOTES_FILE must not be empty');
assert(typeof releaseTag === 'string' && releaseTag.length > 0, 'RELEASE_TAG must not be empty');
assert(typeof packageName === 'string' && packageName.length > 0, 'PACKAGE_NAME must not be empty');
assert(typeof packageVersion === 'string' && packageVersion.length > 0, 'PACKAGE_VERSION must not be empty');
assert(typeof tarballFilename === 'string' && tarballFilename.length > 0, 'TARBALL_FILENAME must not be empty');
assert(typeof checksumFilename === 'string' && checksumFilename.length > 0, 'CHECKSUM_FILENAME must not be empty');
assert(typeof checksumSha256 === 'string' && checksumSha256.length > 0, 'CHECKSUM_SHA256 must not be empty');
assert(typeof githubServerUrl === 'string' && githubServerUrl.length > 0, 'GITHUB_SERVER_URL must not be empty');
assert(typeof githubRepository === 'string' && githubRepository.length > 0, 'GITHUB_REPOSITORY must not be empty');
const communiqueBody = readFileSync(communiqueBodyPath, 'utf8').trim();
assert(communiqueBody.length > 0, 'Communique release body must not be empty');
const tarballUrl = `${githubServerUrl}/${githubRepository}/releases/download/${releaseTag}/${tarballFilename}`;
const installNotes = `
Install from npm once the trusted publish job for this workflow completes:
\`\`\`bash
npm install -g "${packageName}@${packageVersion}"
agent-tty version --json
\`\`\`
If you need a registry-independent fallback, install the verified tarball asset from this release directly:
\`\`\`bash
VERSION=${packageVersion}
RELEASE_TAG=${releaseTag}
TARBALL_URL=${tarballUrl}
npm install -g "$TARBALL_URL"
agent-tty version --json
\`\`\`
For private releases or environments that require authenticated downloads, fetch the asset first and then install locally:
\`\`\`bash
gh release download "${releaseTag}" --repo "${githubRepository}" --pattern "${tarballFilename}"
npm install -g "./${tarballFilename}"
agent-tty version --json
\`\`\`
SHA-256 checksum: \`${checksumSha256}\` (see \`${checksumFilename}\` for the portable checksum file).
This workflow prepares one verified tarball and reuses it across GitHub Release assets and npm publishing instead of rebuilding it.
`
.split('\n')
.map((line) => line.replace(/^ /, ''))
.join('\n')
.trim();
assert(installNotes.length > 0, 'install notes must not be empty');
writeFileSync(releaseNotesPath, `${communiqueBody}\n\n---\n\n${installNotes}\n`);
EOF
- name: Create or update GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_DIR: ${{ runner.temp }}/release-assets
RELEASE_NOTES_FILE: ${{ runner.temp }}/release-notes.md
RELEASE_TAG: ${{ needs.prepare-release.outputs.release_tag }}
RELEASE_TITLE: ${{ steps.communique_notes.outputs.release_title }}
PACKAGE_VERSION: ${{ needs.prepare-release.outputs.package_version }}
TARBALL_FILENAME: ${{ needs.prepare-release.outputs.tarball_filename }}
CHECKSUM_FILENAME: ${{ needs.prepare-release.outputs.checksum_filename }}
run: |
set -euo pipefail
create_flags=()
if [[ "$PACKAGE_VERSION" == *-* ]]; then
create_flags+=(--prerelease --latest=false)
fi
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit \
"$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$RELEASE_TITLE" \
--notes-file "$RELEASE_NOTES_FILE"
gh release upload \
"$RELEASE_TAG" \
"$RELEASE_DIR/$TARBALL_FILENAME" \
"$RELEASE_DIR/$CHECKSUM_FILENAME" \
--repo "$GITHUB_REPOSITORY" \
--clobber
else
gh release create \
"$RELEASE_TAG" \
"$RELEASE_DIR/$TARBALL_FILENAME" \
"$RELEASE_DIR/$CHECKSUM_FILENAME" \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--title "$RELEASE_TITLE" \
--notes-file "$RELEASE_NOTES_FILE" \
"${create_flags[@]}"
fi
publish-npm:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: prepare-release
permissions:
contents: read
id-token: write
steps:
- name: Download verified release artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-assets-${{ needs.prepare-release.outputs.release_tag }}
path: ${{ runner.temp }}/release-assets
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
package-manager-cache: false
registry-url: 'https://registry.npmjs.org'
- name: Publish verified tarball to npm
shell: bash
env:
PACKAGE_NAME: ${{ needs.prepare-release.outputs.package_name }}
PACKAGE_VERSION: ${{ needs.prepare-release.outputs.package_version }}
TARBALL_PATH: ${{ runner.temp }}/release-assets/${{ needs.prepare-release.outputs.tarball_filename }}
run: |
set -euo pipefail
if [[ ! -f "$TARBALL_PATH" ]]; then
echo "Release tarball missing: $TARBALL_PATH" >&2
exit 1
fi
publish_args=()
if [[ "$PACKAGE_NAME" == @* ]]; then
publish_args+=(--access public)
fi
if [[ "$PACKAGE_VERSION" == *-* ]]; then
dist_tag="${PACKAGE_VERSION#*-}"
dist_tag="${dist_tag%%.*}"
if [[ -z "$dist_tag" ]]; then
echo "Unable to derive prerelease dist-tag from $PACKAGE_VERSION" >&2
exit 1
fi
publish_args+=(--tag "$dist_tag")
echo "Publishing prerelease $PACKAGE_NAME@$PACKAGE_VERSION with dist-tag $dist_tag"
else
echo "Publishing stable $PACKAGE_NAME@$PACKAGE_VERSION with dist-tag latest"
fi
npm publish "$TARBALL_PATH" "${publish_args[@]}"