# 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 GitHub build/upload while still retrying the OSS mirror and npm publishing. # Release 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) # -> one program payload per target (four platform payloads bundling the official Node runtime, # a win-x64 payload with runtime + MinGit, and a runtime-less universal payload) -> wrap each # payload, its checksum and the native installer into the canonical installer bundle -> validate # the real bundles -> upload to the Release. # Artifacts (one shape per target, serving online and offline installs alike): # penguin-{linux,darwin}-{x64,arm64}.tar.gz, penguin-universal.tar.gz, penguin-win32-x64.zip, # their .sha256 files, SHA256SUMS, install.sh, and install.ps1; one version per tag, multiple # versions coexist. Releases up to v0.1.5 shipped raw program archives under the same names # plus *-offline wrappers; the installers keep accepting that legacy layout for pinned versions. # - 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. # - mirror-oss: download the exact GitHub Release assets, verify their checksums, then mirror the same bytes # to immutable releases// keys in Alibaba Cloud OSS through GitHub OIDC. The GitHub Environment # `oss-production` supplies the provider/role ARNs and OSS settings. latest.json is uploaded last and only # when the tag is still GitHub's current latest Release. Manual retries also work after a Release exists. 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 # Bundled POSIX shell for the Windows package: Git for Windows' MinGit, whose usr/bin/sh.exe # IS GNU bash (installed under the name `sh`), plus ~60 coreutils and git.exe. Pinned to an # exact release so the shipped bytes are reproducible and the GPLv2 source offer in # THIRD-PARTY-NOTICES.md names one version. Bump deliberately, not automatically. MINGIT_VERSION: 2.55.0.3 MINGIT_TAG: v2.55.0.windows.3 jobs: # Skip the build/upload when the tag's Release already exists (immutable releases forbid # replacing assets, so re-uploading can only fail; OSS mirroring and npm publishing are idempotent). 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 # Desktop installers (Electron shell, three-OS matrix). Runs BEFORE the release job: # Release assets are immutable once published, so the installers must exist when the # Release is created. See design § "桌面端原型 · 打包与更新" (M3). desktop: needs: check-release if: needs.check-release.outputs.exists != 'true' uses: ./.github/workflows/desktop-build.yml with: tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} release: needs: [check-release, desktop] 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@v6 - 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) and both standalone installers. The stamped installers then select the same immutable OSS/GitHub # release when run directly. BUILD_DATE is stamped alongside core with this run's UTC date. - name: Stamp release version env: # Referenced as a plain shell variable so the run block stays free of GitHub # expressions: scripts/test-installer.sh replays this block verbatim as sh. EVENT_NAME: ${{ github.event_name }} run: | TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}" V="${TAG#v}" printf '%s\n' "$TAG" | grep -Eq '^v[0-9A-Za-z][0-9A-Za-z._-]*$' # Guard: release prep must bump the repo to the tag's version (root + packages/*/package.json # and core's VERSION constant move together in the release: X.Y.Z PR). v0.2.1 was tagged with # a 0.2.0 repo, so every dev/source build nagged about updates until the repo caught up — # fail the tag push instead. Manual dispatch (EVENT_NAME unset in replays) only warns, # keeping legacy tags rebuildable; installer-test fixtures have no package.json, so the # check self-skips there. if [ -f package.json ]; then PKG_V="$(node -p 'require("./package.json").version')" if [ "$PKG_V" != "$V" ]; then MSG="tag $TAG does not match the repo version $PKG_V; bump root + packages/*/package.json and core VERSION during release prep, then re-tag (see CONTRIBUTING.md)." if [ "${EVENT_NAME:-}" = "push" ]; then echo "error: $MSG" >&2 exit 1 fi echo "warning: $MSG (continuing: manual dispatch)" >&2 fi fi 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 SH_MARKER='EMBEDDED_RELEASE_VERSION="__PENGUIN_RELEASE_VERSION__"' PS_MARKER='$EmbeddedReleaseVersion = "__PENGUIN_RELEASE_VERSION__"' if grep -Fq "$SH_MARKER" install.sh; then SH_HAS_MARKER=1; else SH_HAS_MARKER=0; fi if grep -Fq "$PS_MARKER" install.ps1; then PS_HAS_MARKER=1; else PS_HAS_MARKER=0; fi case "$SH_HAS_MARKER:$PS_HAS_MARKER" in 1:1) sed -i "s/__PENGUIN_RELEASE_VERSION__/$TAG/g" install.sh sed -i "s/__PENGUIN_RELEASE_VERSION__/$TAG/g" install.ps1 grep -Fq "EMBEDDED_RELEASE_VERSION=\"$TAG\"" install.sh grep -Fq "\$EmbeddedReleaseVersion = \"$TAG\"" install.ps1 ;; 0:0) # Tags created before installer version stamping have neither marker. Preserve their # historical installers so a missing Release can still be rebuilt from the tag source. echo "Legacy tag without embedded installer versions; leaving installers unstamped." ;; *) echo "Installer release-version markers are inconsistent." >&2 exit 1 ;; esac 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); # Use pnpm's current deploy implementation with injected workspace packages; the hoisted target layout # avoids deeply nested .pnpm/node_modules paths that exceed Windows PowerShell 5.1's legacy MAX_PATH limit. # 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 --config.node-linker=hoisted --filter @prismshadow/penguin-cli --prod deploy "$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 # Program payloads: linux runtimes use .tar.xz, darwin .tar.gz (nodejs.org naming); # node/ is only lightly trimmed (drop share/doc and share/man, keep the rest). Payloads # are intermediate files named .tar.gz / win32-x64.zip: the packaging script # below seals each one into the canonical installer bundle that gets published. - name: Package platform + universal payloads run: | mkdir -p payloads 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 printf '{"schemaVersion":1,"target":"%s"}\n' "$os-$arch" > out/penguin/package-manifest.json tar -czf "payloads/$os-$arch.tar.gz" -C out penguin done # Universal payload: no bundled runtime, requires system Node >= 24. rm -rf out/penguin/node printf '{"schemaVersion":1,"target":"universal"}\n' > out/penguin/package-manifest.json tar -czf payloads/universal.tar.gz -C out penguin # Windows payload: 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 a # cmd launcher replacing the sh one (resolves its own dir, defaults PENGUIN_WEB_DIST to # the sibling web\, prefers the bundled node\node.exe, falls back to system node). # Deliberately NO penguin.ps1 launcher: PowerShell prefers .ps1 over .cmd on PATH, and # client Windows defaults to the Restricted execution policy, so shipping one makes the # plain `penguin` command fail with "running scripts is disabled" out of the box. Batch # files are exempt from the policy and both PowerShell and cmd.exe resolve penguin.cmd, # which forwards all args and the exit code. # # It also bundles MinGit under git\, so exec_command has a POSIX shell even on a machine # with no Git for Windows: the shims advertise git\usr\bin\sh.exe as PENGUIN_BUNDLED_SHELL # and the resolver (core's shell.ts) uses it only when the user has no bash of their own. # MinGit is unpacked with its tree intact — MSYS binaries locate /etc relative to the # directory holding msys-2.0.dll, so `sh -lc` finds git\etc\profile and gets the usual # /mingw64/bin:/usr/bin:, keeping System32's curl/tar reachable. - name: Package win-x64 payload 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 # MinGit unzips flat (no top-level directory), so give it its own destination. mingit="MinGit-$MINGIT_VERSION-64-bit.zip" curl -fsSL "https://github.com/git-for-windows/git/releases/download/$MINGIT_TAG/$mingit" -o "/tmp/$mingit" rm -rf out/penguin/git mkdir -p out/penguin/git unzip -q "/tmp/$mingit" -d out/penguin/git # Fail loudly here rather than shipping a package whose shell silently does not exist. test -f out/penguin/git/usr/bin/sh.exe test -f out/penguin/git/etc/profile rm -f out/penguin/bin/penguin printf '{"schemaVersion":1,"target":"win32-x64"}\n' > out/penguin/package-manifest.json 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%\git\usr\bin\sh.exe" set "PENGUIN_BUNDLED_SHELL=%DIR%\git\usr\bin\sh.exe" 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 (cd out && zip -qr ../payloads/win32-x64.zip penguin) # The canonical artifacts: each payload is sealed with its checksum and the native # installer into one flat bundle per target (see scripts/package-release-bundles.sh). - name: Package canonical installer bundles run: sh scripts/package-release-bundles.sh payloads dist-artifacts # Validate the real release outputs, not only the small fixtures used by the script tests. # Every bundle must pass its outer checksum, stay flat with exactly the documented member # set, carry a byte-identical installer and payload, and pass its sealed payload checksum. - name: Validate canonical installer bundles run: | set -eu ARTIFACT_DIR="$PWD/dist-artifacts" PAYLOAD_DIR="$PWD/payloads" WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT validate_layout() { dir="$1" expected="$2" actual="$(find "$dir" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort)" [ -z "$(find "$dir" -mindepth 2 -print -quit)" ] [ "$actual" = "$expected" ] } for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 universal; do bundle="penguin-$target.tar.gz" (cd "$ARTIFACT_DIR" && sha256sum -c "$bundle.sha256") extracted="$WORK_DIR/$target" mkdir -p "$extracted" tar -xzf "$ARTIFACT_DIR/$bundle" -C "$extracted" expected="$(printf '%s\n' install.sh payload.tar.gz payload.tar.gz.sha256 | LC_ALL=C sort)" validate_layout "$extracted" "$expected" [ -x "$extracted/install.sh" ] (cd "$extracted" && sha256sum -c payload.tar.gz.sha256) cmp "$PWD/install.sh" "$extracted/install.sh" cmp "$PAYLOAD_DIR/$target.tar.gz" "$extracted/payload.tar.gz" done bundle="penguin-win32-x64.zip" (cd "$ARTIFACT_DIR" && sha256sum -c "$bundle.sha256") extracted="$WORK_DIR/win32-x64" mkdir -p "$extracted" unzip -q "$ARTIFACT_DIR/$bundle" -d "$extracted" expected="$(printf '%s\n' install.cmd install.ps1 payload.zip payload.zip.sha256 | LC_ALL=C sort)" validate_layout "$extracted" "$expected" (cd "$extracted" && sha256sum -c payload.zip.sha256) cmp "$PWD/install.cmd" "$extracted/install.cmd" cmp "$PWD/install.ps1" "$extracted/install.ps1" cmp "$PAYLOAD_DIR/win32-x64.zip" "$extracted/payload.zip" # Summary covers the six canonical bundles. - name: Generate SHA256SUMS run: | cd dist-artifacts sha256sum -- *.tar.gz *.zip > SHA256SUMS # Desktop installers built by the desktop job (three-OS matrix): collected here so # they are part of the Release's initial (immutable) asset set. Their checksums go # in a separate SHA256SUMS.desktop, which is also how the OSS mirror script verifies # them (its manifest lists the installers by their version-less names). - name: Collect desktop artifacts uses: actions/download-artifact@v4 with: pattern: desktop-* merge-multiple: true path: desktop-artifacts - name: Generate desktop checksums run: | cd desktop-artifacts sha256sum -- * > SHA256SUMS.desktop # Release notes come from changelog//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@v3 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 desktop-artifacts/* install.sh install.ps1 mirror-oss: name: Mirror GitHub Release to Alibaba Cloud OSS needs: [check-release, release] if: >- ${{ always() && needs.check-release.result == 'success' && (needs.release.result == 'success' || needs.release.result == 'skipped') }} runs-on: ubuntu-latest concurrency: group: penguin-oss-production cancel-in-progress: false permissions: contents: read id-token: write environment: name: oss-production url: ${{ vars.OSS_PUBLIC_BASE_URL }} steps: # On workflow_dispatch, use the selected branch's current mirror scripts while downloading # the requested tag's immutable Release assets. A tag push naturally checks out that tag. - uses: actions/checkout@v5 - name: Validate OSS environment configuration env: ALIYUN_OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }} ALIYUN_ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }} OSS_BUCKET: ${{ vars.OSS_BUCKET }} OSS_REGION: ${{ vars.OSS_REGION }} OSS_ENDPOINT: ${{ vars.OSS_ENDPOINT }} OSS_PUBLIC_BASE_URL: ${{ vars.OSS_PUBLIC_BASE_URL }} run: | set -eu for name in ALIYUN_OIDC_PROVIDER_ARN ALIYUN_ROLE_ARN OSS_BUCKET OSS_REGION OSS_ENDPOINT OSS_PUBLIC_BASE_URL; do eval "value=\${$name:-}" if [ -z "$value" ]; then echo "error: $name is not configured in the oss-production environment" >&2 exit 1 fi done - name: Download and verify ossutil run: | sh scripts/install-ossutil.sh "$RUNNER_TEMP/ossutil-bin" echo "$RUNNER_TEMP/ossutil-bin" >> "$GITHUB_PATH" # Pinned v1 commit. audience must match the client ID configured on the Alibaba Cloud # OIDC provider; this project deliberately uses `github-actions`. - name: Exchange GitHub OIDC token for Alibaba Cloud credentials id: aliyun uses: aliyun/configure-aliyun-credentials-action@1e5248c8d5d93a8781ac344a68e19a43341e79e6 with: oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }} role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }} role-session-name: penguin-oss-${{ github.run_id }} role-session-expiration: 1800 audience: github-actions - name: Download exact GitHub Release assets env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} run: | mkdir -p release-assets gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir release-assets - name: Determine whether the tag is the latest Release id: latest env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} run: | LATEST_TAG="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" if [ "$TAG" = "$LATEST_TAG" ]; then echo "update=true" >> "$GITHUB_OUTPUT" else echo "update=false" >> "$GITHUB_OUTPUT" echo "$TAG will be mirrored without replacing latest.json (current latest: $LATEST_TAG)." fi - name: Mirror and verify OSS objects env: TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} GH_TOKEN: ${{ github.token }} OSS_ACCESS_KEY_ID: ${{ steps.aliyun.outputs['aliyun-access-key-id'] }} OSS_ACCESS_KEY_SECRET: ${{ steps.aliyun.outputs['aliyun-access-key-secret'] }} OSS_SESSION_TOKEN: ${{ steps.aliyun.outputs['aliyun-security-token'] }} OSS_BUCKET: ${{ vars.OSS_BUCKET }} OSS_REGION: ${{ vars.OSS_REGION }} OSS_ENDPOINT: ${{ vars.OSS_ENDPOINT }} OSS_PUBLIC_BASE_URL: ${{ vars.OSS_PUBLIC_BASE_URL }} run: sh scripts/publish-release-to-oss.sh release-assets "$TAG" "${{ steps.latest.outputs.update }}" 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@v6 - 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 env: # Same shell-variable indirection as the release job's stamp step (no GitHub # expressions in the body; see scripts/test-installer.sh). EVENT_NAME: ${{ github.event_name }} run: | TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}" V="${TAG#v}" # Same repo-version guard as the release job: both jobs start from a tag push in # parallel, so the check must fail here too or npm would publish from a tag whose # repo version was never bumped. Manual dispatch only warns (legacy-tag retries). if [ -f package.json ]; then PKG_V="$(node -p 'require("./package.json").version')" if [ "$PKG_V" != "$V" ]; then MSG="tag $TAG does not match the repo version $PKG_V; bump root + packages/*/package.json and core VERSION during release prep, then re-tag (see CONTRIBUTING.md)." if [ "${EVENT_NAME:-}" = "push" ]; then echo "error: $MSG" >&2 exit 1 fi echo "warning: $MSG (continuing: manual dispatch)" >&2 fi fi 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