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
107 changes: 64 additions & 43 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,30 @@ name: Release
# (npm run ci + build + audit:openclaw + pack:check) and then publishes the
# package to the public npm registry with provenance.
#
# AUTH: npm TRUSTED PUBLISHING (OIDC) — no NPM_TOKEN. The publish authenticates
# tokenlessly: GitHub Actions presents a short-lived OIDC identity (via the
# `id-token: write` permission below) that npm verifies against the trusted
# publisher configured on the package (org + repo `honeycomb` + this workflow
# filename `release.yaml`). There is NO long-lived automation token anywhere in
# this workflow or in repo secrets. Provenance stays ON (the same OIDC identity
# also signs the supply-chain attestation). Trusted Publishing REQUIRES npm
# >= 11.5.1, which Node 22 does NOT ship (it bundles npm 10.x) — see the "Upgrade
# npm" step below, which is mandatory for OIDC to engage. The trusted publisher
# must be configured on npm BEFORE the first CI publish, and that config requires
# the package to already exist, so the FIRST publish is a one-time manual publish
# (2FA); every subsequent CI publish is tokenless. See RELEASING.md / PRD-048a.
#
# DRAFT STATUS (read RELEASING.md before going public): the package is still
# `private: true` and named `honeycomb` (a name already owned by someone else on
# the public registry). This workflow is wired so a maintainer can go public with
# a few deliberate switches, but it FAILS CLOSED until those switches are flipped:
# * the publishability preflight aborts if `private` is still true OR if the
# name is the taken unscoped `honeycomb` — so a stray tag push cannot publish
# a broken or duplicate package by accident.
# * a real `npm publish` only happens when NPM_TOKEN is set AND this is not a
# dry run; otherwise the job runs the gate + `npm publish --dry-run` and stays
# green (mirrors ci.yaml's gated-integration pattern: skip, don't fail).
# * a real `npm publish` only happens on a tag push AND when this is not a dry
# run; a workflow_dispatch always rehearses (gate + `npm publish --dry-run`)
# and stays green (mirrors ci.yaml's gated-integration pattern: skip, don't
# fail). The publish intent is the TRIGGER (a tag = go live), not a token.
#
# Two ways in:
# * push a version tag `vX.Y.Z` -> a real publish attempt (still gated as above).
Expand Down Expand Up @@ -41,10 +55,13 @@ concurrency:
# Least privilege (mirrors ci.yaml principle #7), scoped UP only for what a
# publish needs:
# * contents: write — ONLY to create the GitHub Release for the pushed tag.
# * id-token: write — the OIDC token npm provenance signs against, so the
# published package carries a verifiable supply-chain
# attestation (`npm publish --provenance`). Without this
# scope provenance generation fails.
# * id-token: write — the short-lived OIDC identity, which now does DOUBLE duty:
# (1) it is the publish AUTH for npm Trusted Publishing
# (npm verifies it against the package's trusted publisher;
# this replaces NPM_TOKEN), AND (2) npm provenance signs
# against it so the published package carries a verifiable
# supply-chain attestation (`npm publish --provenance`).
# Without this scope BOTH tokenless auth and provenance fail.
permissions:
contents: write
id-token: write
Expand All @@ -57,16 +74,28 @@ jobs:
- name: Checkout
uses: actions/checkout@v4.2.2

# registry-url is what wires `npm publish` (and provenance) to the public
# npm registry and makes setup-node write the NODE_AUTH_TOKEN line into
# .npmrc. One pinned Node major — the publish surface does not need the
# full engine matrix (that canary lives on ci.yaml's quality-gate).
# registry-url points `npm publish` (and provenance) at the public npm registry.
# With Trusted Publishing there is NO NODE_AUTH_TOKEN: registry-url may scaffold a
# ~/.npmrc, but we supply no token VALUE — auth is the OIDC identity, not a token.
# Dependency caching is deliberately OFF on this publish job: a lower-privilege
# workflow could poison an npm cache that this high-privilege release build then
# executes (zizmor cache-poisoning). `npm ci` installs from the committed lockfile.
# One pinned Node major — the publish surface does not need the full engine matrix
# (that canary lives on ci.yaml's quality-gate).
- name: Setup Node
uses: actions/setup-node@v6.4.0
Comment thread
thenotoriousllama marked this conversation as resolved.
with:
node-version: '22.x'
registry-url: 'https://registry.npmjs.org'
cache: npm

# Node 22 bundles npm 10.x, but npm Trusted Publishing (OIDC) requires
# npm >= 11.5.1 — without it the OIDC handshake never engages and `npm
# publish` falls back to token auth (which we no longer provide) and fails.
# Upgrade BEFORE npm ci / build / publish so every npm invocation in this job
# is OIDC-capable. Pinned to an EXACT version (not a `^` range) so a release
# never runs an unreviewed npm; bump it deliberately (must stay >= 11.5.1).
- name: Upgrade npm (Trusted Publishing needs npm >= 11.5.1)
run: npm install -g npm@11.6.2

- name: Install (npm ci)
run: npm ci
Expand Down Expand Up @@ -150,38 +179,31 @@ jobs:
fi
echo "OK — package is publishable (not private, scoped name)."

# ── Token gate. ──────────────────────────────────────────────────────────
# secrets are not allowed in a job/step `if:`, so read NPM_TOKEN in a run
# step and emit a boolean. Empty token (before the secret is set, or on a
# fork) -> publish=false -> the publish step does a --dry-run and the job
# stays GREEN (skip, don't fail — same pattern as ci.yaml's secret gate).
- name: Token gate
id: token
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -n "$NPM_TOKEN" ]; then
echo "has_token=true" >> "$GITHUB_OUTPUT"
else
echo "has_token=false" >> "$GITHUB_OUTPUT"
fi

# Decide dry-run vs real publish in one place. A real publish requires ALL:
# * a token is present, AND
# * this is not a workflow_dispatch dry_run.
# On a tag push `inputs.dry_run` is empty, so the only gate is the token.
# ── Resolve publish mode — gated on the TRIGGER, not a token. ─────────────
# Trusted Publishing needs no NPM_TOKEN, so the publish intent is expressed
# by HOW the workflow was triggered. A real publish requires ALL THREE:
# * this is a `push` event (`github.event_name == 'push'`) — NOT a manual
# dispatch (a workflow_dispatch CAN be run against a tag ref, so the
# event-name check is what actually keeps the manual button a rehearsal), AND
# * the pushed ref is a tag (`github.ref_type == 'tag'`) — pushing `vX.Y.Z`
# is the deliberate "go live" signal, AND
# * it is not a dispatch dry_run (`inputs.dry_run != 'true'`; empty on a push).
# The manual dispatch button always rehearses (it is not a `push`), even if a
# maintainer points it at a tag and unchecks dry_run. Real publishes only ever
# come from a pushed `vX.Y.Z` tag.
- name: Resolve publish mode
id: mode
env:
HAS_TOKEN: ${{ steps.token.outputs.has_token }}
EVENT_NAME: ${{ github.event_name }}
REF_TYPE: ${{ github.ref_type }}
DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }}
run: |
if [ "$HAS_TOKEN" = "true" ] && [ "$DRY_RUN_INPUT" != "true" ]; then
if [ "$EVENT_NAME" = "push" ] && [ "$REF_TYPE" = "tag" ] && [ "$DRY_RUN_INPUT" != "true" ]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
echo "Mode: REAL publish (token present, not a dry run)."
echo "Mode: REAL publish (pushed tag, not a dry run) — authenticating via OIDC Trusted Publishing."
else
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "Mode: DRY-RUN (token=$HAS_TOKEN, dry_run_input=$DRY_RUN_INPUT) — running npm publish --dry-run, not publishing."
echo "Mode: DRY-RUN (event=$EVENT_NAME, ref_type=$REF_TYPE, dry_run_input=$DRY_RUN_INPUT) — running npm publish --dry-run, not publishing."
fi

# ── Publish. ─────────────────────────────────────────────────────────────
Expand All @@ -192,27 +214,26 @@ jobs:
# --access public: scoped packages default to restricted; this publishes
# them public. (Also set via publishConfig in
# package.json when going public — belt and suspenders.)
# NODE_AUTH_TOKEN is the .npmrc auth var setup-node wired to registry-url.
# Auth is TOKENLESS: npm (>= 11.5.1, ensured above) performs the OIDC
# handshake using the id-token: write identity and verifies it against the
# package's trusted publisher. There is NO NODE_AUTH_TOKEN / .npmrc auth line.
- name: Publish (real)
if: ${{ steps.mode.outputs.publish == 'true' }}
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# `prepack` rebuilds the bundle here, so the published tarball needs the
# telemetry define present on THIS step too (see Build step comment).
HONEYCOMB_POSTHOG_KEY: ${{ secrets.HONEYCOMB_POSTHOG_KEY }}
HONEYCOMB_REF_DEFAULT: ${{ vars.HONEYCOMB_REF_DEFAULT }}

# Rehearse: same command minus the real upload. Runs whenever we are NOT in
# real-publish mode (dry_run input, or no token yet) so the pipeline is
# exercised end-to-end and stays green before secrets exist.
# real-publish mode (any workflow_dispatch, or a non-tag ref) so the pipeline
# is exercised end-to-end and stays green. `--dry-run` does no OIDC handshake
# and no upload, so it is tokenless by nature — nothing to wire here.
- name: Publish (dry-run rehearsal)
if: ${{ steps.mode.outputs.publish != 'true' }}
run: npm publish --provenance --access public --dry-run
env:
# Harmless when empty; present so the dry-run exercises the same auth
# path the real publish takes.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# The dry-run's `prepack` rebuilds too — keep the define present so the
# rehearsal exercises the same build the real publish takes.
HONEYCOMB_POSTHOG_KEY: ${{ secrets.HONEYCOMB_POSTHOG_KEY }}
Expand Down
97 changes: 69 additions & 28 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ this document is the order to flip them in.
> **TL;DR for the impatient maintainer.** You must (1) pick + provision a
> **scoped** npm name (the unscoped `honeycomb` is already taken — see below),
> (2) add `publishConfig` for public access + provenance, (3) remove
> `private: true`, and (4) set the `NPM_TOKEN` repo secret. Until all four are
> done, `release.yaml` runs the full gate + a `--dry-run` and stays green
> without ever publishing.
> `private: true`, and (4) **configure GitHub Actions as the package's trusted
> publisher on npm** (tokenless OIDC — no `NPM_TOKEN`). Until the three in-repo
> switches are flipped, `release.yaml` runs the full gate + a `--dry-run` and
> stays green without ever publishing.
>
> **Auth is npm Trusted Publishing (OIDC), not a token.** CI authenticates with a
> short-lived GitHub OIDC identity that npm verifies against the trusted publisher
> you configure on the package — there is **no `NPM_TOKEN` secret** anywhere.
> Provenance stays on (same identity). One catch: a trusted publisher can only be
> set on a package that already exists, so the **first** publish is a one-time
> manual 2FA publish; every CI publish after that is tokenless.

---

Expand All @@ -24,7 +32,9 @@ use a **scoped** name under an npm org/scope you control:

- `@legioncodeinc/honeycomb` (matches the GitHub org `legioncodeinc`), or
- `@legioncode/honeycomb`, or any scope whose npm org exists and lists the
publishing identity (you, or the CI automation token) as a member.
publishing identity (you, as an org member) with publish rights. CI itself
publishes tokenlessly via the trusted publisher (switch (d)), not via a member
account.

The release workflow has a **fail-closed preflight** that aborts the publish if
`package.json` `name` is still the unscoped `honeycomb` OR if `private: true` is
Expand All @@ -38,9 +48,10 @@ these switches are flipped.
### (a) Choose + provision the scoped name

1. Decide the scope (e.g. `@legioncodeinc`). The corresponding **npm org/scope
must already exist** on npmjs.com, and the publishing identity (your npm
account, or the automation token in step (d)) **must be a member** with
publish rights. Create the org at npmjs.com if it does not exist.
must already exist** on npmjs.com, and your npm account **must be a member**
with publish rights (for the bootstrap publish and break-glass; CI publishes
via the trusted publisher in step (d), not an account). Create the org at
npmjs.com if it does not exist.
2. Set it in `package.json`:
```jsonc
"name": "@legioncodeinc/honeycomb",
Expand Down Expand Up @@ -78,16 +89,36 @@ run at all (npm itself blocks it), which is why the draft is safe.
> and do a local-install dogfood (below) **without** removing `private` — leave
> it set until you genuinely intend to go live.

### (d) Set the `NPM_TOKEN` repo secret

1. On npmjs.com, create an **automation** access token (type: Automation, so it
bypasses 2FA in CI) scoped to publish for the package/scope.
2. In GitHub → repo **Settings → Secrets and variables → Actions → New
repository secret**, add it as `NPM_TOKEN`.

Until this secret exists, `release.yaml` does the gate + `npm publish --dry-run`
and stays green (it will not hard-fail on a missing token) — same skip-don't-fail
pattern as `ci.yaml`'s gated integration job.
### (d) Configure the trusted publisher on npm (tokenless OIDC — no `NPM_TOKEN`)

CI authenticates via **npm Trusted Publishing**: GitHub Actions presents a
short-lived OIDC identity that npm verifies against a trusted publisher you
configure on the package. There is **no `NPM_TOKEN` secret** — nothing
long-lived to leak or rotate.

1. **First publish is a one-time manual bootstrap.** A trusted publisher can only
be attached to a package that already exists on npm. So the very first release
is a manual, interactive (2FA) `npm publish` by an org member with publish
rights — this creates `@legioncodeinc/honeycomb` on the registry. (See "Cut
the release" below for the bootstrap-vs-subsequent split.)
2. **Attach the trusted publisher.** On npmjs.com → the package → **Settings →
Trusted Publishers → GitHub Actions**, add:
- **Organization / user:** `legioncodeinc`
- **Repository:** `honeycomb`
- **Workflow filename:** `release.yaml`
- **Environment:** optional — leave blank, or set one and add a matching
`environment:` to the publish job in `release.yaml` for an extra approval gate.
3. **Do NOT set an `NPM_TOKEN` secret.** The workflow does not read one. The only
release secret is `HONEYCOMB_POSTHOG_KEY` (PRD-050e telemetry), set separately.

> **npm version floor.** Trusted Publishing requires **npm >= 11.5.1**. Node 22
> bundles npm 10.x, so `release.yaml` upgrades npm (`npm i -g npm@^11.5.1`) before
> publish — without it the OIDC handshake never engages and the publish fails.

Until the package exists + the trusted publisher is attached, a real CI publish
cannot succeed; before then, `release.yaml` on a `workflow_dispatch` does the gate
+ `npm publish --dry-run` and stays green (the `--dry-run` does no OIDC handshake)
— same skip-don't-fail pattern as `ci.yaml`'s gated integration job.

---

Expand Down Expand Up @@ -133,15 +164,22 @@ Once (a)–(d) are flipped and a dry-run rehearsal is green:
> commit. CI's tag-vs-`package.json` guard checks the root version against
> the tag; sync-versions keeps the harness manifests honest with it.

> **Bootstrap (first release only).** The trusted publisher cannot be attached
> until the package exists on npm, so the FIRST publish is a one-time manual
> publish by an org member: `npm publish --access public` (interactive, 2FA),
> then attach the trusted publisher per switch (d). Every release after the
> first is the tokenless CI flow below.

2. **Push the tag.**
```sh
git push --follow-tags
```
The `vX.Y.Z` tag triggers `release.yaml`. With `NPM_TOKEN` set and this being
a tag push (not a dry-run dispatch), the workflow runs the full gate, the
tag-vs-version guard, the publishability preflight, then
`npm publish --provenance --access public`, and finally creates the GitHub
Release for the tag.
The `vX.Y.Z` tag triggers `release.yaml`. Because this is a tag push (not a
dry-run dispatch), the workflow upgrades npm (>= 11.5.1), runs the full gate,
the tag-vs-version guard, the publishability preflight, then
`npm publish --provenance --access public` — authenticating **tokenlessly via
OIDC** against the trusted publisher (no `NPM_TOKEN`) — and finally creates the
GitHub Release for the tag.

---

Expand All @@ -168,18 +206,21 @@ Once (a)–(d) are flipped and a dry-run rehearsal is green:
`.github/workflows/release.yaml` triggers on a `v*` tag push or a manual
`workflow_dispatch` (`dry_run` defaults true). It:

1. checks out, sets up Node 22 against `registry.npmjs.org`, `npm ci`;
1. checks out, sets up Node 22 against `registry.npmjs.org`, **upgrades npm to
>= 11.5.1** (required for Trusted Publishing OIDC), `npm ci`;
2. runs the **full gate** — `npm run ci` + `npm run build` +
`npm run audit:openclaw` + `npm run pack:check` (same recipe as `ci.yaml`);
3. **tag-vs-`package.json` guard** — the pushed `vX.Y.Z` must equal
`package.json` version (skipped on dispatch);
4. **publishability preflight (fail-closed)** — aborts if `private: true` or if
`name` is the taken unscoped `honeycomb`;
5. **publishes** with `--provenance --access public` — but ONLY when `NPM_TOKEN`
is set AND it is not a dry run; otherwise it does `npm publish --dry-run` and
stays green;
5. **publishes** with `--provenance --access public`, **authenticating tokenlessly
via OIDC** against the trusted publisher (no `NPM_TOKEN`) — but ONLY on a tag
push that is not a dry run; otherwise it does `npm publish --dry-run` and stays
green;
6. creates the **GitHub Release** for the tag (real publishes only).

`permissions` are least-privilege: `contents: write` only to create the GitHub
Release, `id-token: write` only so npm provenance can sign an OIDC supply-chain
attestation.
Release, `id-token: write` doing double duty — it is both the **publish auth** for
npm Trusted Publishing (replacing `NPM_TOKEN`) and the identity npm provenance
signs its OIDC supply-chain attestation against.
Loading
Loading