chore(release): 0.3.0 (#119) #6
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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[@]}" |