Skip to content

Commit f4f9a5e

Browse files
authored
feat(npm): add @mininglamp-oss/octo-cli npm wrapper (#27)
Closes #31 ## What Distributes `octo-cli` via npm so Node-based agent runtimes (OpenClaw, etc.) can `npm install -g @mininglamp-oss/octo-cli`. The package is a thin wrapper around the prebuilt Go binary. - **`npm/`** — `package.json` (bin: `octo-cli`, version is a `0.0.0` placeholder filled at publish time), `install.js` (postinstall: download the matching release archive → verify sha256 → extract), `run.js` (shim that execs the binary), `README` - **`install.js` PATH hint** — on a global install, if npm's bin dir isn't on `PATH`, it prints a shell-specific `export PATH=…` hint (zsh/bash/fish/Windows). Warn only; never edits config. - **`.github/workflows/npm-publish.yml`** — publishes on GitHub Release `published` - **`.gitignore`** — ignore `npm/bin/` (the downloaded binary) - **`README`** — npm install section ## Aligned with octo-adapters' publish flow After comparing with `publish-octo.yml` / `create-openclaw-octo`: - ✅ `--access public` (scoped packages default to private) - ✅ `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` via `setup-node` - ✅ **dist-tag rule**: a prerelease tag (contains `-`, e.g. `v0.6.0-rc.1`) publishes to `@next`; stable to `@latest` — so an rc never clobbers `@latest` - ✅ SHA-pinned `checkout` / `setup-node`, `permissions: {}`, `concurrency` guard - ✅ `workflow_dispatch` with a `--dry-run` path for manual testing **Deliberately NOT adopted:** the dev-publish-on-every-merge workflow. octo-cli's wrapper downloads a binary from a *matching release*, so its npm version must track a real release tag — a per-merge `-dev.<sha>` version would have no matching release artifact and produce an uninstallable package. ## Dependencies / merge order - Builds on the `octo` → `octo-cli` rename (#26, merged): the wrapper downloads `octo-cli_<version>_<os>_<arch>` archives. - Therefore it can only **publish** from a release whose goreleaser archives use the `octo-cli_` prefix — i.e. **v0.6.0+**. Merging this PR is safe before then; it just won't publish until such a release is cut and `NPM_TOKEN` is set (already configured on this repo). ## Testing - `node --check` on install.js / run.js: clean - PATH-hint unit exercised across global/non-global, on/off-PATH, zsh/fish - `npm pack` (with a real version) produces `mininglamp-oss-octo-cli-<v>.tgz` - workflow: no tabs, valid YAML, `--access public` + dist-tag logic present > Note: the npm scope `@mininglamp-oss` ownership / `NPM_TOKEN` were sorted out separately (token is configured as a repo secret on octo-cli).
1 parent ff4e739 commit f4f9a5e

12 files changed

Lines changed: 1450 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ jobs:
1818
matrix:
1919
go-version: ["1.24.x"]
2020
steps:
21-
- uses: actions/checkout@v4
21+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
2222

23-
- uses: actions/setup-go@v5
23+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
2424
with:
2525
go-version: ${{ matrix.go-version }}
2626
check-latest: true
@@ -58,3 +58,17 @@ jobs:
5858

5959
- name: build
6060
run: go build -o octo-cli ./cmd/octo-cli
61+
62+
npm-test:
63+
name: npm test
64+
runs-on: ubuntu-latest
65+
defaults:
66+
run:
67+
working-directory: npm
68+
steps:
69+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
70+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
71+
with:
72+
node-version: "20"
73+
- name: node --test
74+
run: node --test scripts/install.test.js

.github/workflows/npm-publish.yml

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
# Publish the npm wrapper (@mininglamp-oss/octo-cli).
2+
#
3+
# This workflow is sequenced AFTER `release-publish.yml`'s `build-artifacts`
4+
# job: that job ends by explicitly invoking us with `gh workflow run` (i.e.
5+
# a workflow_dispatch event), so by the time we run the goreleaser archives
6+
# and `checksums.txt` are already on the GitHub Release. There is no
7+
# `on: release: published` trigger: that event is fired by GITHUB_TOKEN
8+
# (in the release-publish flow), which GitHub's recursion-prevention rule
9+
# would suppress, so it never actually delivered.
10+
#
11+
# Tag → npm dist-tag:
12+
# v1.2.3 → @latest (matches /^v\d+\.\d+\.\d+$/)
13+
# v1.2.3-rc.1 → @next (any tag containing "-")
14+
#
15+
# A manual workflow_dispatch run defaults to --dry-run, so re-running by
16+
# hand is safe and doesn't accidentally re-publish.
17+
name: npm publish
18+
19+
on:
20+
workflow_dispatch:
21+
inputs:
22+
tag:
23+
description: "Release tag to publish (e.g. v0.6.0)"
24+
required: true
25+
type: string
26+
dry_run:
27+
description: "Run npm publish with --dry-run (no actual upload)"
28+
required: false
29+
type: boolean
30+
default: true
31+
32+
concurrency:
33+
# Key on the version so two dispatches of the same tag still serialize.
34+
group: npm-publish-${{ inputs.tag }}
35+
cancel-in-progress: false
36+
37+
permissions: {}
38+
39+
jobs:
40+
publish:
41+
runs-on: ubuntu-latest
42+
# contents:read is required by actions/checkout under GITHUB_TOKEN auth
43+
# (works without it on public repos via the unauthenticated-clone
44+
# fallback, but that's not a contract we want to rely on).
45+
# actions:read lets the CI-evidence gate query workflow_runs for the
46+
# tagged commit, mirroring release-publish.yml's validate_run_id check.
47+
# id-token:write lets npm provenance generate an OIDC-signed Sigstore
48+
# attestation linking the published tarball back to this workflow run.
49+
permissions:
50+
contents: read
51+
actions: read
52+
id-token: write
53+
defaults:
54+
run:
55+
working-directory: npm
56+
steps:
57+
# Resolve runs BEFORE checkout — only reads inputs and writes outputs,
58+
# no filesystem dependency. Putting it first means a rejected input
59+
# never reaches actions/checkout, so attacker-supplied refs can't
60+
# land any content in the workspace before validation runs.
61+
#
62+
# `working-directory: .` overrides the job-level default of `npm`,
63+
# which doesn't exist yet (checkout is the next step).
64+
#
65+
# IMPORTANT: dispatch inputs are read through env: shell variables,
66+
# not interpolated into the run: block. Direct `${{ inputs.tag }}`
67+
# would be substituted as literal text before bash parses the line,
68+
# letting a crafted tag value break out of the assignment and run
69+
# arbitrary commands in a step that later has NPM_TOKEN in scope
70+
# (CWE-94).
71+
- name: Resolve version and dist-tag
72+
id: version
73+
working-directory: .
74+
env:
75+
INPUT_TAG: ${{ inputs.tag }}
76+
INPUT_DRY_RUN: ${{ inputs.dry_run }}
77+
run: |
78+
REF="$INPUT_TAG"
79+
if [ "$INPUT_DRY_RUN" = "true" ]; then DRY="--dry-run"; else DRY=""; fi
80+
# Strict v-prefixed semver. Bare semver like "0.6.0" is rejected
81+
# so that the downstream `refs/tags/$INPUT_TAG` checkout cannot
82+
# be tricked into picking up an attacker-created branch named
83+
# "0.6.0" (no v) while the release gate validates the legitimate
84+
# "v0.6.0" release. Enforcing the 'v' here keeps every consumer
85+
# in lockstep: checkout, gate, set-version, and publish all see
86+
# the same normalized form.
87+
if [[ ! "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
88+
echo "::error::inputs.tag must be v-prefixed semver (got '$REF'). Bare semver like '0.6.0' is rejected to prevent branch/tag-name confusion."
89+
exit 1
90+
fi
91+
VERSION="${REF#v}"
92+
if [[ "$VERSION" == *-* ]]; then DIST_TAG="next"; else DIST_TAG="latest"; fi
93+
# TAG is the v-prefixed form (same as INPUT_TAG, just spelled out
94+
# for downstream readability). Consumers: checkout ref below and
95+
# the "Verify release exists and is published" step.
96+
{
97+
echo "VERSION=$VERSION"
98+
echo "TAG=v$VERSION"
99+
echo "DIST_TAG=$DIST_TAG"
100+
echo "DRY_RUN=$DRY"
101+
} >> "$GITHUB_OUTPUT"
102+
echo "[npm-publish] version=$VERSION dist-tag=$DIST_TAG dry_run='$DRY'"
103+
104+
# Pin to the release tag so the wrapper code in the published npm
105+
# tarball matches the commit that the GitHub Release was cut from.
106+
# Without `refs/tags/` prefix, actions/checkout will accept a same-
107+
# named branch — letting an attacker who creates a branch matching
108+
# the tag substitute branch contents into the published tarball
109+
# while the gate still validates the legitimate release.
110+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
111+
with:
112+
ref: refs/tags/${{ steps.version.outputs.TAG }}
113+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
114+
with:
115+
node-version: "20"
116+
registry-url: "https://registry.npmjs.org"
117+
118+
# Gate (part 1 of 2) — release existence and publication state.
119+
# Refuse to publish unless `tag` corresponds to a real, already-
120+
# published (non-draft) GitHub Release. This alone is not sufficient:
121+
# a repo:write actor can create a Release via the API/UI without
122+
# going through release-publish.yml, bypassing its validate_run_id
123+
# check. The "Verify CI evidence" step below closes that loop by
124+
# mirroring the reusable workflow's invariant — together the two
125+
# gates assert (a) a Release exists for the tag, and (b) the tagged
126+
# commit has a successful CI run on record. Skipped on dry_run so
127+
# developers can validate workflow plumbing against tags whose
128+
# Release doesn't exist yet.
129+
- name: Verify release exists and is published
130+
if: steps.version.outputs.DRY_RUN == ''
131+
env:
132+
GH_TOKEN: ${{ github.token }}
133+
TAG: ${{ steps.version.outputs.TAG }}
134+
run: |
135+
set -euo pipefail
136+
137+
# Footgun guard #1 — `gh release view ""` does NOT error: it
138+
# resolves to the latest release. If a future regression makes
139+
# this output empty (it has happened in this PR's history), the
140+
# gate would silently validate the latest release instead of the
141+
# requested tag and let an arbitrary ref through. Bail explicitly.
142+
if [ -z "$TAG" ]; then
143+
echo "::error::Empty TAG output from resolver step — gate cannot run. This is an internal workflow bug, not an input error."
144+
exit 1
145+
fi
146+
147+
info=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,tagName 2>/dev/null) || {
148+
echo "::error::No GitHub Release for $TAG. npm-publish requires a published Release for the tag — use release-publish.yml to cut one; direct workflow_dispatch of an arbitrary tag is not permitted."
149+
exit 1
150+
}
151+
152+
# Footgun guard #2 — even with a non-empty TAG, defensively assert
153+
# gh actually returned the release we asked for. Belt-and-braces
154+
# against any future gh CLI change that adds a different
155+
# silently-resolves-to-latest fallback.
156+
returned_tag=$(printf '%s' "$info" | jq -r '.tagName')
157+
if [ "$returned_tag" != "$TAG" ]; then
158+
echo "::error::gh release view returned a different tag ('$returned_tag') than requested ('$TAG'). Refusing to publish."
159+
exit 1
160+
fi
161+
162+
is_draft=$(printf '%s' "$info" | jq -r '.isDraft')
163+
if [ "$is_draft" = "true" ]; then
164+
echo "::error::Release $TAG is still a draft; publish it first (release-publish.yml flips it out of draft after artifacts upload)."
165+
exit 1
166+
fi
167+
echo "[npm-publish] release $TAG exists, is published, and matches the requested tag — proceeding."
168+
169+
# Gate (part 2 of 2) — CI evidence on the tagged commit.
170+
# release-publish.yml's reusable workflow requires the operator to
171+
# supply `validate_run_id` (a successful CI run whose head_sha is
172+
# the tagged commit). That check enforces "the commit you're about
173+
# to publish actually passed CI." Without an equivalent here, a
174+
# repo:write actor could bypass release-publish.yml entirely:
175+
#
176+
# 1. push tag v9.9.9 at any commit (no CI required to push a tag)
177+
# 2. create a published Release for v9.9.9 via API/UI
178+
# 3. dispatch this workflow
179+
#
180+
# The release-existence gate above passes (a real, non-draft
181+
# Release exists), checkout pulls refs/tags/v9.9.9, and npm publish
182+
# ships the attacker-controlled commit with a valid Sigstore
183+
# provenance attestation. This step closes that path by re-doing
184+
# the validate_run_id check inline: resolve the tag to its commit
185+
# SHA (handling annotated tags) and require at least one successful
186+
# CI workflow run on that SHA. Without a CI pass, refuse.
187+
#
188+
# Skipped on dry_run for the same reason as the release-existence
189+
# gate (developer plumbing tests against tags that may not exist).
190+
- name: Verify CI evidence for the tagged commit
191+
if: steps.version.outputs.DRY_RUN == ''
192+
env:
193+
GH_TOKEN: ${{ github.token }}
194+
TAG: ${{ steps.version.outputs.TAG }}
195+
run: |
196+
set -euo pipefail
197+
198+
# Resolve refs/tags/$TAG → commit SHA. Handle annotated tags
199+
# (which point at a tag object that in turn points at a commit)
200+
# by dereferencing one extra hop.
201+
ref_data=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG")
202+
ref_type=$(printf '%s' "$ref_data" | jq -r '.object.type')
203+
ref_sha=$(printf '%s' "$ref_data" | jq -r '.object.sha')
204+
if [ "$ref_type" = "tag" ]; then
205+
tag_obj=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$ref_sha")
206+
target_type=$(printf '%s' "$tag_obj" | jq -r '.object.type')
207+
if [ "$target_type" != "commit" ]; then
208+
echo "::error::Annotated tag $TAG points to '$target_type', not a commit. Refusing to publish."
209+
exit 1
210+
fi
211+
ref_sha=$(printf '%s' "$tag_obj" | jq -r '.object.sha')
212+
fi
213+
214+
# Require at least one successful "CI" workflow run on this
215+
# exact commit. Matches release-publish.yml/reusable's
216+
# ci_workflow_name default (`CI`); if that workflow is ever
217+
# renamed, update both. The query filters by head_sha and
218+
# status=success server-side so we don't iterate a long list.
219+
ci_count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$ref_sha&status=success&per_page=100" \
220+
--jq '[.workflow_runs[] | select(.name == "CI")] | length')
221+
if [ "$ci_count" -eq 0 ]; then
222+
echo "::error::No successful CI run found for $TAG (commit $ref_sha). The tagged commit must have passed CI before it can be published to npm. Push the commit through a PR to main, wait for CI to pass, then cut the release via release-publish.yml."
223+
exit 1
224+
fi
225+
echo "[npm-publish] CI evidence OK: $ci_count successful CI run(s) on commit $ref_sha for tag $TAG."
226+
227+
- name: Set package version
228+
env:
229+
VERSION: ${{ steps.version.outputs.VERSION }}
230+
run: npm version "$VERSION" --no-git-tag-version --allow-same-version
231+
232+
# `--provenance` produces an OIDC-signed Sigstore attestation linking
233+
# the tarball to this workflow run (npm ≥ 9.5, public repo + public
234+
# package — both satisfied). Consumers can verify the link with
235+
# `npm audit signatures`; this is the only origin link the user gets
236+
# for the wrapper itself (the binary's sha256 check is independent).
237+
#
238+
# The dry-run path is split into a separate branch rather than relying
239+
# on an unquoted `$DRY_RUN` expansion (SC2086 / actionlint). The value
240+
# is workflow-derived and never user-controlled, so the original idiom
241+
# was safe — but actionlint flags it and clarity isn't worse this way.
242+
- name: Publish to npm
243+
env:
244+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
245+
DIST_TAG: ${{ steps.version.outputs.DIST_TAG }}
246+
DRY_RUN: ${{ steps.version.outputs.DRY_RUN }}
247+
run: |
248+
if [ -n "$DRY_RUN" ]; then
249+
npm publish --access public --provenance --tag "$DIST_TAG" --dry-run
250+
else
251+
npm publish --access public --provenance --tag "$DIST_TAG"
252+
fi

.github/workflows/release-publish.yml

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,54 @@ jobs:
3636
runs-on: ubuntu-latest
3737
permissions:
3838
contents: write
39+
# actions:write lets the final step dispatch npm-publish.yml. We do this
40+
# explicitly rather than letting npm-publish hook `on: release: published`,
41+
# because the publish job above changes the release state using
42+
# GITHUB_TOKEN, and GitHub's recursion rule suppresses workflow runs for
43+
# events caused by GITHUB_TOKEN — workflow_dispatch and
44+
# repository_dispatch are the only exempt events.
45+
actions: write
3946
steps:
47+
# Validate the tag input BEFORE checkout so attacker-supplied refs
48+
# never land in the workspace. Same regex as npm-publish.yml's
49+
# resolver: strict v-prefixed MAJOR.MINOR.PATCH (+ optional
50+
# prerelease). Bare semver like "0.6.0" is rejected because the
51+
# checkout below uses `refs/tags/${{ inputs.tag }}` — a non-v input
52+
# would either fail to resolve (good) or pick up an attacker-created
53+
# tag named without the v (bad), so reject ambiguous shapes up front.
54+
- name: Validate tag input (pre-checkout)
55+
env:
56+
TAG: ${{ inputs.tag }}
57+
run: |
58+
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
59+
echo "::error::inputs.tag must be v-prefixed semver (got '$TAG')."
60+
exit 1
61+
fi
62+
63+
# SHA-pinned actions. build-artifacts holds contents:write +
64+
# actions:write and runs third-party code (goreleaser-action) that
65+
# builds the binaries whose checksums become the install-time trust
66+
# root, so any compromise here propagates into every install.
67+
#
68+
# `refs/tags/` prefix is required: with a bare ref like `v0.6.0`,
69+
# actions/checkout will accept either a tag OR a same-named branch.
70+
# An attacker creating a branch matching the tag could substitute
71+
# the build source while the release evidence still refers to the
72+
# legitimate tag. Forcing the tags namespace disambiguates.
4073
- name: Checkout
41-
uses: actions/checkout@v4
74+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
4275
with:
43-
ref: ${{ inputs.tag }}
76+
ref: refs/tags/${{ inputs.tag }}
4477
fetch-depth: 0
4578

4679
- name: Set up Go
47-
uses: actions/setup-go@v5
80+
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
4881
with:
4982
go-version: "1.24.x"
5083
cache: true
5184

5285
- name: Build with GoReleaser
53-
uses: goreleaser/goreleaser-action@v6
86+
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
5487
with:
5588
version: "~> v2"
5689
args: release --clean --skip=publish,announce
@@ -60,14 +93,27 @@ jobs:
6093
- name: List dist artifacts
6194
run: ls -lh dist/
6295

63-
- name: Validate tag before upload
64-
run: |
65-
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?([-.][0-9A-Za-z.-]+)?$ ]] || { echo "Invalid tag: $TAG"; exit 1; }
66-
env:
67-
TAG: ${{ inputs.tag }}
68-
6996
- name: Upload artifacts to release
7097
run: gh release upload "$TAG" dist/*.tar.gz dist/*.zip dist/checksums.txt --clobber
7198
env:
7299
TAG: ${{ inputs.tag }}
73100
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
101+
102+
# Hand off to the npm wrapper publish workflow. Sequenced after upload
103+
# so the archives + checksums.txt are guaranteed to be on the release
104+
# by the time the npm postinstall tries to download them. Uses
105+
# workflow_dispatch (exempt from GITHUB_TOKEN recursion suppression).
106+
#
107+
# No `--ref` argument: gh dispatches against the repo's default
108+
# branch, so the npm-publish.yml definition is taken from a trusted
109+
# ref (not from the release tag, which could in theory be a fake one
110+
# in a branch/tag ambiguity scenario). The packaging contents are
111+
# separately pinned by npm-publish.yml's internal checkout of
112+
# `refs/tags/${{ inputs.tag }}`, so the published tarball still
113+
# matches the release commit. This separates "which workflow file
114+
# runs" (trusted) from "which source it packages" (release tag).
115+
- name: Trigger npm publish
116+
env:
117+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
118+
TAG: ${{ inputs.tag }}
119+
run: gh workflow run npm-publish.yml -f tag="$TAG" -f dry_run=false

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Binary (root only)
22
/octo-cli
33
/bin/
4+
# npm postinstall downloads the prebuilt binary here — never commit it
5+
/npm/bin/
46
coverage.out
57

68
# Go

.goreleaser.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ archives:
2828
- LICENSE*
2929

3030
checksum:
31+
# Pin the algorithm so it doesn't silently drift with goreleaser defaults.
32+
# Lockstep with npm/scripts/install.js's parseChecksumEntry, which asserts
33+
# `^[0-9a-f]{64}$` (64 lowercase hex chars = sha256). If you change this
34+
# algorithm here, update the regex and the corresponding test in
35+
# npm/scripts/install.test.js.
36+
algorithm: sha256
3137
name_template: "checksums.txt"
3238

3339
changelog:

0 commit comments

Comments
 (0)