e1141ca010
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
342 lines
18 KiB
YAML
342 lines
18 KiB
YAML
# Release: tag v* -> build the one-line install artifacts and publish a GitHub Release.
|
|
# Releases are immutable: once published, assets can never be replaced. A tiny check-release
|
|
# job therefore gates the release job on the tag's Release not existing yet — dispatching an
|
|
# already-released tag skips the build/upload entirely and only re-runs npm publishing.
|
|
# Two parallel jobs (the release job is gated on the existence check):
|
|
# - release: build the monorepo -> pnpm deploy a production CLI dir -> assemble penguin/ (bin + lib + web)
|
|
# -> five platform packages each bundling the official Node runtime + a universal package -> SHA256 files -> upload to the Release.
|
|
# Artifacts: penguin-{linux,darwin}-{x64,arm64}.tar.gz, penguin-win32-x64.zip, penguin-universal.tar.gz,
|
|
# their .sha256 files, SHA256SUMS, install.sh, and install.ps1; one version per tag, multiple versions coexist.
|
|
# - publish-npm: publish the whole chain (@prismshadow/penguin-skills -> @prismshadow/penguin-core
|
|
# -> @prismshadow/penguin-server -> @prismshadow/penguin-cli) to npm at the tag version.
|
|
# skills/core serve the penguin-sdk Skill's `npm install`; server ships the built web assets inside
|
|
# the package (web-dist/, its default web dir falls back to it), so `npm install -g
|
|
# @prismshadow/penguin-cli` alone yields a working `penguin` incl. the Web UI (needs Node >= 24).
|
|
# The publish flow mirrors AgentHub's publish.yml: OIDC trusted publishing (environment: npm +
|
|
# id-token: write, no token). A Trusted Publisher can only be configured in the settings page of a
|
|
# package that ALREADY EXISTS on the registry, so a brand-new package cannot be first-published by
|
|
# this workflow. Release checklist for a new package: (1) a maintainer bootstrap-publishes it once
|
|
# manually with a one-off granular token (revoke it afterwards), running the same prepare steps as
|
|
# this job (stamp versions, build, copy LICENSE + web-dist) and publishing with `pnpm publish
|
|
# --access public --no-git-checks` -- NEVER `npm publish`, which keeps workspace:* deps unrewritten
|
|
# and yields a package that fails to install (Unsupported URL Type "workspace:");
|
|
# (2) configure this repo + workflow as its Trusted Publisher on npmjs; (3) subsequent tags publish
|
|
# via OIDC. The publish step is idempotent (versions already on the registry are skipped), so a tag
|
|
# that failed mid-chain can be re-run as-is after fixing the config.
|
|
# npm publishing lives here rather than a separate `on: release: published` workflow: the Release is created
|
|
# by this workflow's GITHUB_TOKEN, and GitHub won't trigger other workflows' release events from that.
|
|
name: Release
|
|
|
|
on:
|
|
push:
|
|
tags: ["v*"]
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: "Release tag (e.g. v0.1.0)"
|
|
required: true
|
|
|
|
env:
|
|
# Bundled Node runtime version (official nodejs.org dist, aligned with engines >=24).
|
|
NODE_RUNTIME_VERSION: v24.18.0
|
|
|
|
jobs:
|
|
# Skip the build/upload when the tag's Release already exists (immutable releases forbid
|
|
# replacing assets, so re-uploading can only fail; npm publishing is idempotent on its own).
|
|
check-release:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
exists: ${{ steps.check.outputs.exists }}
|
|
steps:
|
|
- id: check
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
|
run: |
|
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
release:
|
|
needs: check-release
|
|
if: needs.check-release.outputs.exists != 'true'
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
# On manual dispatch, check out the tag itself (not the selected branch HEAD): a tag whose Release is
|
|
# missing (e.g. an earlier run failed before publishing) rebuilds from the tag's own source. On tag-push,
|
|
# leaving ref empty is the default.
|
|
- uses: actions/checkout@v5
|
|
with:
|
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }}
|
|
|
|
# pnpm version comes from package.json's packageManager field.
|
|
- uses: pnpm/action-setup@v4
|
|
|
|
- uses: actions/setup-node@v5
|
|
with:
|
|
node-version: 24
|
|
cache: pnpm
|
|
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# Inject the release tag into core's VERSION constant (the source for CLI --version and the install-complete
|
|
# message); otherwise artifacts always carry the in-repo dev version and multiple installs can't be told apart.
|
|
# BUILD_DATE is stamped alongside it with this run's UTC date (the repo keeps null for dev builds).
|
|
- name: Stamp release version
|
|
run: |
|
|
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
|
|
V="${TAG#v}"
|
|
grep -q 'export const VERSION = "' packages/core/src/index.ts
|
|
sed -i "s/export const VERSION = \"[^\"]*\"/export const VERSION = \"$V\"/" packages/core/src/index.ts
|
|
D="$(date -u +%Y-%m-%d)"
|
|
# BRE: the unescaped | in these patterns is literal (grep/sed default to POSIX BRE, where | is not alternation).
|
|
grep -q 'export const BUILD_DATE: string | null = ' packages/core/src/index.ts
|
|
sed -i "s/export const BUILD_DATE: string | null = [^;]*/export const BUILD_DATE: string | null = \"$D\"/" packages/core/src/index.ts
|
|
|
|
- name: Build (tsup + vite)
|
|
run: pnpm build
|
|
|
|
# lib/: the CLI and its production deps (including workspace core/server/skills, all build outputs);
|
|
# pnpm 10's deploy needs --legacy (this repo doesn't enable inject-workspace-packages).
|
|
# bin/penguin launcher: resolve its own real path (following symlinks) -> default PENGUIN_WEB_DIST to
|
|
# the sibling web/ -> use the bundled runtime (node/bin/node) if present, else fall back to system node.
|
|
- name: Assemble penguin/ (lib + web + bin)
|
|
run: |
|
|
pnpm --filter @prismshadow/penguin-cli --prod deploy --legacy "$PWD/out/penguin/lib"
|
|
cp -r packages/web/dist out/penguin/web
|
|
mkdir -p out/penguin/bin
|
|
cat > out/penguin/bin/penguin <<'EOF'
|
|
#!/bin/sh
|
|
SELF="$0"
|
|
while [ -h "$SELF" ]; do
|
|
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
|
SELF="$(readlink "$SELF")"
|
|
case "$SELF" in /*) ;; *) SELF="$DIR/$SELF" ;; esac
|
|
done
|
|
DIR="$(cd "$(dirname "$SELF")/.." && pwd)"
|
|
export PENGUIN_WEB_DIST="${PENGUIN_WEB_DIST:-$DIR/web}"
|
|
if [ -x "$DIR/node/bin/node" ]; then
|
|
exec "$DIR/node/bin/node" "$DIR/lib/dist/index.js" "$@"
|
|
fi
|
|
exec node "$DIR/lib/dist/index.js" "$@"
|
|
EOF
|
|
chmod +x out/penguin/bin/penguin
|
|
|
|
# Platform packages: linux uses .tar.xz, darwin uses .tar.gz (nodejs.org naming);
|
|
# node/ is only lightly trimmed (drop share/doc and share/man, keep the rest).
|
|
- name: Package platform + universal tarballs
|
|
run: |
|
|
mkdir -p dist-artifacts
|
|
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
|
|
os="${target%%-*}"
|
|
arch="${target#*-}"
|
|
name="node-$NODE_RUNTIME_VERSION-$os-$arch"
|
|
if [ "$os" = "linux" ]; then ext="tar.xz"; else ext="tar.gz"; fi
|
|
curl -fsSL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$name.$ext" -o "/tmp/$name.$ext"
|
|
rm -rf /tmp/node-runtime out/penguin/node
|
|
mkdir -p /tmp/node-runtime
|
|
if [ "$ext" = "tar.xz" ]; then
|
|
tar -xJf "/tmp/$name.$ext" -C /tmp/node-runtime
|
|
else
|
|
tar -xzf "/tmp/$name.$ext" -C /tmp/node-runtime
|
|
fi
|
|
mv "/tmp/node-runtime/$name" out/penguin/node
|
|
rm -rf out/penguin/node/share/doc out/penguin/node/share/man
|
|
tar -czf "dist-artifacts/penguin-$os-$arch.tar.gz" -C out penguin
|
|
done
|
|
# Universal package: no bundled runtime, requires system Node >= 24.
|
|
rm -rf out/penguin/node
|
|
tar -czf dist-artifacts/penguin-universal.tar.gz -C out penguin
|
|
|
|
# Windows package: same lib/ + web/ layout, but a .zip (the native format), the official
|
|
# win-x64 Node runtime — whose zip has node.exe at the ARCHIVE ROOT, not bin/ — and
|
|
# cmd/ps1 launchers replacing the sh one (resolve their own dir, default PENGUIN_WEB_DIST
|
|
# to the sibling web\, prefer the bundled node\node.exe, fall back to system node).
|
|
# install.ps1 verifies and unpacks this zip; in PowerShell the .ps1 shim wins over .cmd,
|
|
# in cmd.exe only the .cmd is found — both forward all args and the exit code.
|
|
- name: Package win-x64 zip
|
|
run: |
|
|
name="node-$NODE_RUNTIME_VERSION-win-x64"
|
|
curl -fsSL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$name.zip" -o "/tmp/$name.zip"
|
|
rm -rf /tmp/node-runtime out/penguin/node
|
|
mkdir -p /tmp/node-runtime
|
|
unzip -q "/tmp/$name.zip" -d /tmp/node-runtime
|
|
mv "/tmp/node-runtime/$name" out/penguin/node
|
|
rm -f out/penguin/bin/penguin
|
|
cat > out/penguin/bin/penguin.cmd <<'EOF'
|
|
@echo off
|
|
setlocal
|
|
set "DIR=%~dp0.."
|
|
if not defined PENGUIN_WEB_DIST set "PENGUIN_WEB_DIST=%DIR%\web"
|
|
if exist "%DIR%\node\node.exe" (
|
|
"%DIR%\node\node.exe" "%DIR%\lib\dist\index.js" %*
|
|
) else (
|
|
node "%DIR%\lib\dist\index.js" %*
|
|
)
|
|
exit /b %ERRORLEVEL%
|
|
EOF
|
|
# cmd.exe is only fully reliable with CRLF batch files.
|
|
sed -i 's/$/\r/' out/penguin/bin/penguin.cmd
|
|
cat > out/penguin/bin/penguin.ps1 <<'EOF'
|
|
$dir = Split-Path -Parent $PSScriptRoot
|
|
if (-not $env:PENGUIN_WEB_DIST) { $env:PENGUIN_WEB_DIST = Join-Path $dir "web" }
|
|
$node = Join-Path $dir "node\node.exe"
|
|
if (-not (Test-Path $node)) { $node = "node" }
|
|
& $node (Join-Path $dir "lib\dist\index.js") @args
|
|
exit $LASTEXITCODE
|
|
EOF
|
|
# PowerShell accepts LF, but ship CRLF like the .cmd (and the repo's *.ps1 eol=crlf attribute).
|
|
sed -i 's/$/\r/' out/penguin/bin/penguin.ps1
|
|
(cd out && zip -qr ../dist-artifacts/penguin-win32-x64.zip penguin)
|
|
|
|
# SHA256SUMS summary + a same-named .sha256 per artifact (install.sh / install.ps1 verify against the latter).
|
|
- name: Generate SHA256 checksums
|
|
run: |
|
|
cd dist-artifacts
|
|
sha256sum *.tar.gz *.zip > SHA256SUMS
|
|
for f in *.tar.gz *.zip; do
|
|
sha256sum "$f" > "$f.sha256"
|
|
done
|
|
|
|
# Release notes come from changelog/<version>/RELEASE.md, written during release preparation and
|
|
# committed BEFORE the tag (the release job runs on the tag's checkout, so a file added afterwards
|
|
# is invisible here). Present and non-empty -> published verbatim as the body; absent -> GitHub
|
|
# generates a body from the merged PRs. Either way the Release can never ship an empty body, which
|
|
# is what happened to v0.1.1: the step passed neither body nor generate_release_notes, the action
|
|
# left the body unset, and the omission was only visible once the Release was public.
|
|
- name: Resolve release notes
|
|
id: notes
|
|
env:
|
|
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
|
run: |
|
|
NOTES="changelog/${TAG#v}/RELEASE.md"
|
|
if [ -s "$NOTES" ]; then
|
|
echo "Publishing curated release notes from $NOTES"
|
|
echo "body_path=$NOTES" >> "$GITHUB_OUTPUT"
|
|
echo "generate=false" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "No $NOTES; falling back to GitHub-generated notes."
|
|
echo "body_path=" >> "$GITHUB_OUTPUT"
|
|
echo "generate=true" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
# On tag-push use the ref name; on manual dispatch use the input tag.
|
|
# An empty body_path is falsy for the action and simply ignored (it never tries to read it), so the
|
|
# fallback branch cleanly leaves generate_release_notes to compose the body on its own.
|
|
- name: Publish GitHub Release
|
|
uses: softprops/action-gh-release@v2
|
|
with:
|
|
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
|
body_path: ${{ steps.notes.outputs.body_path }}
|
|
generate_release_notes: ${{ steps.notes.outputs.generate }}
|
|
files: |
|
|
dist-artifacts/*.tar.gz
|
|
dist-artifacts/*.zip
|
|
dist-artifacts/*.sha256
|
|
dist-artifacts/SHA256SUMS
|
|
install.sh
|
|
install.ps1
|
|
|
|
publish-npm:
|
|
name: Publish npm packages
|
|
runs-on: ubuntu-latest
|
|
# OIDC trusted publishing (same as AgentHub's publish.yml): no token; npmjs establishes trust via
|
|
# environment `npm` + this workflow. A publish failure doesn't affect the release job (independent, parallel).
|
|
permissions:
|
|
id-token: write
|
|
contents: read
|
|
environment:
|
|
name: npm
|
|
url: https://www.npmjs.com/package/@prismshadow/penguin-cli
|
|
steps:
|
|
- uses: actions/checkout@v5
|
|
with:
|
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }}
|
|
|
|
- uses: pnpm/action-setup@v4
|
|
|
|
- uses: actions/setup-node@v5
|
|
with:
|
|
node-version: 24
|
|
cache: pnpm
|
|
registry-url: "https://registry.npmjs.org"
|
|
|
|
# npm Trusted Publishing (OIDC) requires npm CLI >= 11.5.1: Node 24 ships npm 11.x, which satisfies it;
|
|
# this just asserts the version to guard against regressions -- pnpm publish's registry auth ultimately
|
|
# delegates to system npm, ordinary CI doesn't exercise OIDC (so a green run won't catch it), and too old
|
|
# a version only surfaces as an auth failure when actually publishing a tag.
|
|
- name: Assert npm supports trusted publishing (>= 11.5.1)
|
|
run: |
|
|
V="$(npm --version)"
|
|
echo "npm $V"
|
|
node -e 'const [M, m, p] = process.argv[1].split(".").map(Number); if (M < 11 || (M === 11 && (m < 5 || (m === 5 && p < 1)))) { console.error("npm " + process.argv[1] + " < 11.5.1"); process.exit(1); }' "$V"
|
|
|
|
- name: Install dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
# Version always comes from the tag: package version and core's VERSION constant are injected together (the
|
|
# repo keeps the dev version). All published packages must bump in lockstep -- pnpm publish rewrites every
|
|
# workspace:* dep to the dependency's current version, so the versions must match.
|
|
# BUILD_DATE is stamped alongside VERSION with this run's UTC date (the repo keeps null for dev builds).
|
|
- name: Stamp release version
|
|
run: |
|
|
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
|
|
V="${TAG#v}"
|
|
grep -q 'export const VERSION = "' packages/core/src/index.ts
|
|
sed -i "s/export const VERSION = \"[^\"]*\"/export const VERSION = \"$V\"/" packages/core/src/index.ts
|
|
D="$(date -u +%Y-%m-%d)"
|
|
# BRE: the unescaped | in these patterns is literal (grep/sed default to POSIX BRE, where | is not alternation).
|
|
grep -q 'export const BUILD_DATE: string | null = ' packages/core/src/index.ts
|
|
sed -i "s/export const BUILD_DATE: string | null = [^;]*/export const BUILD_DATE: string | null = \"$D\"/" packages/core/src/index.ts
|
|
(cd packages/skills && npm version --no-git-tag-version --allow-same-version "$V")
|
|
(cd packages/core && npm version --no-git-tag-version --allow-same-version "$V")
|
|
(cd packages/server && npm version --no-git-tag-version --allow-same-version "$V")
|
|
(cd packages/cli && npm version --no-git-tag-version --allow-same-version "$V")
|
|
|
|
# Dependency order: cli bundles core's dist (tsup noExternal), server/web need core's types;
|
|
# web is built here only to be copied into the server package below.
|
|
- name: Build and test
|
|
run: |
|
|
pnpm --filter @prismshadow/penguin-skills build
|
|
pnpm --filter @prismshadow/penguin-core build
|
|
pnpm --filter @prismshadow/penguin-server build
|
|
pnpm --filter @prismshadow/penguin-web build
|
|
pnpm --filter @prismshadow/penguin-cli build
|
|
pnpm --filter @prismshadow/penguin-skills test
|
|
pnpm --filter @prismshadow/penguin-core test
|
|
pnpm --filter @prismshadow/penguin-server test
|
|
pnpm --filter @prismshadow/penguin-cli test
|
|
|
|
# Copy LICENSE into the package dirs (the files allowlist includes it, so artifacts ship the license)
|
|
# and the built web assets into the server package (web-dist/, in its files allowlist: an npm install
|
|
# serves the Web UI from there without PENGUIN_WEB_DIST).
|
|
# Idempotent: a version already on the registry is skipped. The check tests `npm view`'s output
|
|
# rather than its exit code -- for a missing version of an existing package, older npm exits 0 and
|
|
# newer npm exits 1, but the output is non-empty only when the version exists. Re-running the tag
|
|
# after a mid-chain failure (e.g. Trusted Publisher not configured yet) picks up where it left off.
|
|
- name: Publish to npm
|
|
run: |
|
|
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
|
|
V="${TAG#v}"
|
|
cp LICENSE packages/skills/LICENSE
|
|
cp LICENSE packages/core/LICENSE
|
|
cp LICENSE packages/server/LICENSE
|
|
cp LICENSE packages/cli/LICENSE
|
|
rm -rf packages/server/web-dist
|
|
cp -r packages/web/dist packages/server/web-dist
|
|
for pkg in skills core server cli; do
|
|
name="@prismshadow/penguin-$pkg"
|
|
if [ -n "$(npm view "$name@$V" version 2>/dev/null || true)" ]; then
|
|
echo "$name@$V already on the registry, skipping."
|
|
continue
|
|
fi
|
|
pnpm --filter "$name" publish --access public --no-git-checks
|
|
done
|