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
73 changes: 48 additions & 25 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
with:
bun-version: latest

- uses: actions/setup-node@v6
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 22

Expand All @@ -42,34 +42,30 @@ jobs:
npm --version

- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile

- name: Build Packages
- name: Verify release version before building
env:
AICTRL_VERSION: ${{ github.event.release.tag_name }}
run: |
export AICTRL_VERSION="${AICTRL_VERSION#v}"
# Expose stripped version to subsequent steps so the publish-if-new.sh
# catalog:/workspace: resolver (and the smoke test below) can see it
# without each step re-doing the v-prefix strip. #72.
echo "AICTRL_VERSION=$AICTRL_VERSION" >> "$GITHUB_ENV"
bun turbo build

- name: Verify the release tag matches the @aictrl/cli package version
# publish-if-new.sh publishes @aictrl/cli with the version in
# packages/cli/package.json (only the platform binaries are stamped
# with the tag). If the two disagree, the platform binaries publish
# under the tag while @aictrl/cli silently re-tries an already
# published version and the release half-ships (v0.4.4, first run).
# Fail before publishing anything.
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
AICTRL_VERSION="${RELEASE_TAG#v}"
if [ -z "$AICTRL_VERSION" ]; then
echo "::error::AICTRL_VERSION is empty. This workflow must run from a published release tag."
exit 1
fi
PKG_VERSION=$(node -p "require('./packages/cli/package.json').version")
if [ "$PKG_VERSION" != "$AICTRL_VERSION" ]; then
echo "::error::Release tag version $AICTRL_VERSION does not match packages/cli/package.json version $PKG_VERSION. Bump package.json on main, then re-create the release."
echo "::error::Release tag version $AICTRL_VERSION does not match packages/cli/package.json version $PKG_VERSION. Bump package.json on main before creating the release."
exit 1
fi
# Platform binaries, wrapper and smoke test must use one version.
echo "AICTRL_VERSION=$AICTRL_VERSION" >> "$GITHUB_ENV"
echo "Release version $AICTRL_VERSION matches packages/cli/package.json"

- name: Build Packages
run: bun turbo build

- name: Publish @aictrl/util
working-directory: packages/util
run: ${{ github.workspace }}/.github/scripts/publish-if-new.sh "@aictrl/util"
Expand Down Expand Up @@ -173,24 +169,48 @@ jobs:
working-directory: packages/cli
run: ${{ github.workspace }}/.github/scripts/publish-if-new.sh "@aictrl/cli"

smoke:
needs: publish
runs-on: ubuntu-latest
# Registry install and binary execution receive no publication permissions.
permissions: {}
steps:
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 22

- name: Smoke test - verify @aictrl/cli installs cleanly via npm
# Catches the v0.3.3-class bug where the published manifest carries
# Bun-only protocols (workspace:*, catalog:) that plain `npm install`
# rejects with EUNSUPPORTEDPROTOCOL. Without this gate, a broken
# publish goes unnoticed until consumers report install failures.
# #72.
timeout-minutes: 15
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
AICTRL_VERSION="${RELEASE_TAG#v}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Validate semver tag before npm spec and GITHUB_ENV.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #128, .github/workflows/publish.yml:193-206):

Problem: Validate semver tag before npm spec and GITHUB_ENV
Detail: The smoke job derives AICTRL_VERSION from github.event.release.tag_name with only the "v" prefix stripped and an empty-check, then interpolates it into an npm package spec (`npm install "@aictrl/cli@${AICTRL_VERSION}"`, line 204) and executes the installed `aictrl` bin (line 206); the publish job appends the same derived string to $GITHUB_ENV (line 63). Full quoting blocks shell injection, but npm accepts alias/URL spec syntax (npm:<pkg>, git/https URLs), so a crafted tag could redirect the install to an arbitrary registry package whose binary the step then runs. Today the publish job's strict PKG_VERSION equality gate plus `needs: publish` ordering means a non-matching tag never reaches the smoke job, so this is defense-in-depth rather than an exploitable path.
Suggested fix: After stripping the "v" prefix, validate the format before use in both jobs, e.g. `if ! printf '%s' "$AICTRL_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then echo "::error::Non-semver release tag: $RELEASE_TAG"; exit 1; fi`. This closes the npm alias/URL spec sink and the $GITHUB_ENV write independently of the package.json comparison.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The smoke job derives AICTRL_VERSION from github.event.release.tag_name with only the "v" prefix stripped and an empty-check, then interpolates it into an npm package spec (npm install "@aictrl/cli@${AICTRL_VERSION}", line 204) and executes the installed aictrl bin (line 206); the publish job appends the same derived string to $GITHUB_ENV (line 63). Full quoting blocks shell injection, but npm accepts alias/URL spec syntax (npm:, git/https URLs), so a crafted tag could redirect the install to an arbitrary registry package whose binary the step then runs. Today the publish job's strict PKG_VERSION equality gate plus needs: publish ordering means a non-matching tag never reaches the smoke job, so this is defense-in-depth rather than an exploitable path.

          RELEASE_TAG: ${{ github.event.release.tag_name }}
        run: |
          set -euo pipefail
          AICTRL_VERSION="${RELEASE_TAG#v}"
          smoke="${RUNNER_TEMP}/aictrl-publish-smoke"
          rm -rf "$smoke"
          mkdir -p "$smoke"
          cd "$smoke"
          npm init -y > /dev/null

smoke="${RUNNER_TEMP}/aictrl-publish-smoke"
rm -rf "$smoke"
mkdir -p "$smoke"
cd "$smoke"
npm init -y > /dev/null

# CDN propagation can lag ~30-60s after publish; retry briefly.
for attempt in 1 2 3 4 5; do
# v0.4.4 took longer than 2.5 minutes to become installable (#126).
# Ten attempts allow 8.5 minutes of propagation backoff, bounded by
# the step timeout even if npm itself stalls.
for attempt in {1..10}; do
if npm install "@aictrl/cli@${AICTRL_VERSION}" 2>&1 | tee install.log; then
echo "::notice::npm install @aictrl/cli@${AICTRL_VERSION} succeeded on attempt ${attempt}"
if ! INSTALLED_VERSION=$(./node_modules/.bin/aictrl --version); then
echo "::error::Installed CLI binary could not run for @aictrl/cli@${AICTRL_VERSION}"
exit 1
fi
if [ "$INSTALLED_VERSION" != "$AICTRL_VERSION" ]; then
echo "::error::Installed CLI reports $INSTALLED_VERSION, expected $AICTRL_VERSION"
exit 1
fi
exit 0
fi
# EUNSUPPORTEDPROTOCOL means the resolver in publish-if-new.sh
Expand All @@ -199,8 +219,11 @@ jobs:
echo "::error::@aictrl/cli@${AICTRL_VERSION} ships protocol-prefixed deps that plain npm cannot resolve. The catalog:/workspace: resolver in publish-if-new.sh did not run or did not cover all fields. See aictrl-dev/cli#72."
exit 1
fi
echo "::warning::install attempt ${attempt} failed (likely CDN propagation), retrying in 30s..."
sleep 30
if [ "$attempt" -eq 10 ]; then break; fi
delay=60
if [ "$attempt" -eq 1 ]; then delay=30; fi
echo "::warning::install attempt ${attempt} failed, retrying in ${delay}s..."
sleep "$delay"
done
echo "::error::npm install @aictrl/cli@${AICTRL_VERSION} failed after 5 attempts"
echo "::error::npm install @aictrl/cli@${AICTRL_VERSION} failed after 10 attempts"
exit 1
53 changes: 53 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Releasing aictrl CLI

1. Prepare a PR against `main` with the fixes, the version bump in
`packages/cli/package.json`, and the matching `packages/cli` workspace
version in `bun.lock`. Run `bun install --frozen-lockfile`.
2. Run the release regressions from `packages/cli`:

```bash
bun test test/cli/run-mcp-discovery.test.ts test/mcp/discovery-recovery.test.ts test/cli/publish-workflow.test.ts test/cli/run-provider-finish.test.ts test/cli/run-signal-cancellation.test.ts test/cli/classify-session-error.test.ts test/session/idle.test.ts test/session/processor-idle.test.ts
```

Wait for CI build, workspace typecheck and tests to pass and address reviews.

3. Merge the approved PR and record the resulting `main` commit SHA. Confirm
its package version matches the intended tag. Publish the release against
that exact commit, using reviewed notes:

```bash
gh release create v<version> --repo aictrl-dev/cli --target <merged-main-SHA> --title v<version> --notes-file <notes-file>
gh run list --repo aictrl-dev/cli --workflow publish.yml --limit 3
```

Do not use the legacy `script/release` or `script/publish.ts`; they refer to
retired branches/workflows and do not implement these gates.

4. Require the **Publish to npm** workflow to be green. It checks the tag
against the wrapper package version before building, publishes the platform
packages and wrapper, then retries npm installation through its propagation
window and verifies the installed binary version. If publishing partially
succeeds, inspect the failure before rerunning: npm versions are immutable.
5. Independently install `@aictrl/cli@<version>` in a temporary directory and
verify `./node_modules/.bin/aictrl --version` equals `<version>`.
6. In a separate aictrl application PR, update `docker/executor/Dockerfile` to
the published version. Build the executor image and rerun the headless
discovery and idle-stream fixtures against its binary. Promote through
`sandbox` before `main`; verify a sandbox review persists MCP findings and
a stalled stream reports a timeout rather than waiting for the job limit.

## 0.4.5 candidate

- MCP tools are discovered once per connection and retained for both the
startup catalog and model turns (CLI #127). Tool-list change notifications
refresh that catalog; a failed refresh or closed connection stops the next
turn with an actionable error, instead of submitting a reduced toolset.
A successful later notification or reconnection restores discovery health.
Initially unavailable optional MCP servers keep their existing behavior.
- Release version checks and npm propagation handling address CLI #126.
- The executor enables the existing five-minute model stream idle guard in
application #5891. The CLI retains its generic opt-in default; local tools
retain the extended allowance and explicit operator overrides remain valid.

Publication and executor promotion are separate gates. Preparing this candidate
PR does not publish npm packages or deploy an executor image.
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.4.4",
"version": "0.4.5",
"name": "@aictrl/cli",
"description": "Headless execution engine for AI agent skills",
"type": "module",
Expand Down
Loading
Loading