From 204cc2d2a31b11ecf584de9ff5d9597b7ff24c64 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Tue, 25 Aug 2026 17:19:45 -0400
Subject: [PATCH] fix(release): stage ECC 2.2 launch safely
---
.github/workflows/release.yml | 41 +++++-
.github/workflows/reusable-release.yml | 41 +++++-
CHANGELOG.md | 6 +-
README.md | 35 ++---
docs/ANTIGRAVITY-GUIDE.md | 18 +--
docs/releases/2.2.0/launch-runbook.md | 133 ++++++++++++++++++
docs/releases/2.2.0/release-notes.md | 7 +-
docs/testing/ecc-2.2-release-readiness.tdd.md | 25 +++-
manifests/install-components.json | 2 +-
manifests/install-modules.json | 2 +-
scripts/ecc.js | 2 +-
.../lib/install/opencode-legacy-migration.js | 71 +++++++---
scripts/nasiko.js | 2 +-
skills/nasiko-control-plane/SKILL.md | 8 +-
.../nasiko-control-plane/agents/openai.yaml | 4 +-
tests/ci/nasiko-control-plane.test.js | 6 +-
tests/docs/antigravity-guide.test.js | 18 +--
tests/docs/release-2.2-copy.test.js | 40 ++++++
tests/docs/release-2.2-launch-runbook.test.js | 22 +++
tests/lib/opencode-legacy-migration.test.js | 65 +++++++++
tests/scripts/release-publish.test.js | 21 +++
21 files changed, 494 insertions(+), 75 deletions(-)
create mode 100644 docs/releases/2.2.0/launch-runbook.md
create mode 100644 tests/docs/release-2.2-copy.test.js
create mode 100644 tests/docs/release-2.2-launch-runbook.test.js
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 01dd257d7..d7e886ba5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,6 +14,9 @@ jobs:
outputs:
already_published: ${{ steps.npm_publish_state.outputs.already_published }}
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
+ publish_tag: ${{ steps.npm_publish_state.outputs.publish_tag }}
+ package_name: ${{ steps.npm_publish_state.outputs.package_name }}
+ package_version: ${{ steps.npm_publish_state.outputs.package_version }}
package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }}
@@ -79,6 +82,7 @@ jobs:
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
+ NPM_PUBLISH_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'staged'")
set +e
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
NPM_STATUS=$?
@@ -92,7 +96,10 @@ jobs:
printf '%s\n' "$NPM_LOOKUP"
exit "$NPM_STATUS"
fi
+ echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
+ echo "package_version=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
+ echo "publish_tag=${NPM_PUBLISH_TAG}" >> "$GITHUB_OUTPUT"
- name: Use reviewed release notes
env:
@@ -192,8 +199,40 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
+ NPM_PUBLISH_TAG: ${{ needs.verify.outputs.publish_tag }}
+ run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_PUBLISH_TAG}"
+
+ - name: Verify published npm artifact
+ env:
+ ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
+ PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
+ PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
+ run: |
+ REGISTRY_INTEGRITY=""
+ for ATTEMPT in 1 2 3 4 5 6; do
+ set +e
+ REGISTRY_INTEGRITY=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity 2>&1)
+ NPM_STATUS=$?
+ set -e
+ if [ "$NPM_STATUS" -eq 0 ]; then
+ break
+ fi
+ if [ "$ATTEMPT" -eq 6 ]; then
+ echo "::error::Published npm artifact was not readable after six attempts"
+ printf '%s\n' "$REGISTRY_INTEGRITY"
+ exit "$NPM_STATUS"
+ fi
+ sleep 5
+ done
+ ECC_REGISTRY_INTEGRITY="$REGISTRY_INTEGRITY" node -e "const crypto = require('crypto'); const fs = require('fs'); const expected = process.env.ECC_REGISTRY_INTEGRITY; if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(expected || '')) throw new Error('Invalid published registry integrity'); const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.env.ECC_RELEASE_PACKAGE)).digest('base64'); if (actual !== expected) throw new Error('Published npm artifact does not match tested candidate')"
+
+ - name: Promote verified npm version
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
+ PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
- run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
+ run: npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" "${NPM_DIST_TAG}"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml
index 392ccfb09..e004443be 100644
--- a/.github/workflows/reusable-release.yml
+++ b/.github/workflows/reusable-release.yml
@@ -27,6 +27,9 @@ jobs:
outputs:
already_published: ${{ steps.npm_publish_state.outputs.already_published }}
dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }}
+ publish_tag: ${{ steps.npm_publish_state.outputs.publish_tag }}
+ package_name: ${{ steps.npm_publish_state.outputs.package_name }}
+ package_version: ${{ steps.npm_publish_state.outputs.package_version }}
package_file: ${{ steps.pack.outputs.package_file }}
package_sha256: ${{ steps.pack.outputs.package_sha256 }}
@@ -93,6 +96,7 @@ jobs:
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
+ NPM_PUBLISH_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'staged'")
set +e
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
NPM_STATUS=$?
@@ -106,7 +110,10 @@ jobs:
printf '%s\n' "$NPM_LOOKUP"
exit "$NPM_STATUS"
fi
+ echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
+ echo "package_version=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT"
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
+ echo "publish_tag=${NPM_PUBLISH_TAG}" >> "$GITHUB_OUTPUT"
- name: Use reviewed release notes
env:
@@ -206,8 +213,40 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
+ NPM_PUBLISH_TAG: ${{ needs.verify.outputs.publish_tag }}
+ run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_PUBLISH_TAG}"
+
+ - name: Verify published npm artifact
+ env:
+ ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
+ PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
+ PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
+ run: |
+ REGISTRY_INTEGRITY=""
+ for ATTEMPT in 1 2 3 4 5 6; do
+ set +e
+ REGISTRY_INTEGRITY=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity 2>&1)
+ NPM_STATUS=$?
+ set -e
+ if [ "$NPM_STATUS" -eq 0 ]; then
+ break
+ fi
+ if [ "$ATTEMPT" -eq 6 ]; then
+ echo "::error::Published npm artifact was not readable after six attempts"
+ printf '%s\n' "$REGISTRY_INTEGRITY"
+ exit "$NPM_STATUS"
+ fi
+ sleep 5
+ done
+ ECC_REGISTRY_INTEGRITY="$REGISTRY_INTEGRITY" node -e "const crypto = require('crypto'); const fs = require('fs'); const expected = process.env.ECC_REGISTRY_INTEGRITY; if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(expected || '')) throw new Error('Invalid published registry integrity'); const actual = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(process.env.ECC_RELEASE_PACKAGE)).digest('base64'); if (actual !== expected) throw new Error('Published npm artifact does not match tested candidate')"
+
+ - name: Promote verified npm version
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ PACKAGE_NAME: ${{ needs.verify.outputs.package_name }}
+ PACKAGE_VERSION: ${{ needs.verify.outputs.package_version }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
- run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
+ run: npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" "${NPM_DIST_TAG}"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0dfb0eb96..a84156134 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@
- Guided, manifest-driven setup across supported harnesses, with exact install-state ownership, health checks, repair, and uninstall workflows.
- Native Antigravity 2.0 installation under `.agents/`, including rules, workflows, skills, and adapted agents, plus a cross-platform installation guide.
-- New workflow and operator capabilities including the Itô skill family, Nasiko integration, multi-model council review, dev-team collaboration, agent evaluation, living-docs governance, secure terminal opening, and TasteForge multimodal workflows.
+- New workflow and operator capabilities including the Itô skill family, an experimental Nasiko CLI lifecycle bridge, multi-model council review, dev-team collaboration, agent evaluation, living-docs governance, secure terminal opening, and TasteForge multimodal workflows.
- A thin Pi adapter and expanded cross-harness support, release artifact lifecycle testing, Docker-based CLI testing, and stronger Python validation.
### Changed
@@ -16,14 +16,14 @@
- Default MCP connector set reduced to a single connector (`chrome-devtools`) per the new connector policy (`docs/MCP-CONNECTOR-POLICY.md`). The six previous defaults (`github`, `context7`, `exa`, `memory`, `playwright`, `sequential-thinking`) were retired after the June 2026 audit: their jobs are covered by skills wrapping CLIs/REST APIs (`github-ops`, `documentation-lookup`, `exa-search`, e2e skills) or by harness-native features (memory, extended thinking, web search). All six remain opt-in via `mcp-configs/mcp-servers.json`.
- OpenCode home installs now use its canonical `~/.config/opencode` location, safely discover and migrate unchanged ECC-managed files from legacy `~/.opencode` installs, and preserve modified legacy files for review. Bundled agents inherit the model selected by the user instead of pinning an Anthropic provider.
- `skill-comply` is now part of the install manifest and npm distribution, with generated Python caches excluded from both install and package surfaces.
-- Release automation now verifies the tag is exactly on `origin/main`, fails closed on npm registry errors, tests the exact packed artifact across Linux, macOS, and Windows, publishes npm before creating the GitHub Release, and uses reviewed release notes.
+- Release automation now verifies the tag is exactly on `origin/main`, fails closed on npm registry errors, tests the exact packed artifact across Linux, macOS, and Windows, publishes stable versions to a staging dist-tag, verifies registry bytes before promoting `latest`, creates the GitHub Release after promotion, and uses reviewed release notes.
### Fixed
- `ecc memory` writes and `--body-file` reads failed on Windows under Node 22.12-22.16 and 24.0-24.1. libuv resolved path-based `stat()`/`lstat()` through `GetFileInformationByName` without setting the volume serial, while `fstat()` reported it, so the memory vault's TOCTOU guard rejected every operation. Fixed upstream in libuv 1.51.0; the guard no longer depends on the runtime's patch level. The guard's stat calls now request `BigInt` values, so Windows file IDs past `Number.MAX_SAFE_INTEGER` can no longer collapse two distinct files into one identity.
- Selective reinstall now merges the prior ownership ledger, so later module additions do not orphan files from earlier installs and uninstall removes the complete managed surface.
- Legacy Codex sync uninstall now uses ownership evidence, preserves user files, and requires an explicit opt-in for weaker marker-only cleanup.
-- Nasiko lifecycle operations now recover locks only after confirming the recorded owner is dead, preserve replacement locks, strictly reject malformed tar sizes, padding, terminators, and trailing data, and fail uninstall when staged files remain.
+- The experimental Nasiko CLI lifecycle bridge now recovers locks only after confirming the recorded owner is dead, preserves replacement locks, strictly rejects malformed tar sizes, padding, terminators, and trailing data, and fails uninstall when staged files remain.
- Hook, plan-canvas, session, memory, observer, skill-evolution, Discord delivery, and Windows compatibility regressions fixed across the runtime.
### Release audit
diff --git a/README.md b/README.md
index 2e3183efb..e52afb5c5 100644
--- a/README.md
+++ b/README.md
@@ -76,8 +76,8 @@ Run these commands inside Claude Code:
That installs ECC's skills, agents, commands, and plugin-managed hooks. If you choose this path, stop there. Do not also run a full manual install into Claude Code.
-> Guided package setup is coming in `ecc-universal` 2.2.0. Use the native
-> Claude plugin commands above while npm remains on 2.1.0.
+> ECC 2.2 includes guided package setup through `ecc-universal`. The native
+> Claude plugin commands above remain the simplest Claude Code install path.
@@ -168,16 +168,18 @@ Access to 68 agents, 286 skills, and 94 legacy command shims, plus hooks, rules,
## Install ECC
> [!IMPORTANT]
-> Guided package setup is coming in `ecc-universal` 2.2.0. The current npm
-> release, 2.1.0, does not include the guided setup commands. Use the native
-> Claude plugin commands at the top of this README until 2.2.0 is published.
+> ECC 2.2 includes guided package setup for Claude Code, Codex, and Kimi Code.
+> During registry propagation, run `npm view ecc-universal version` before
+> using the package commands. If it still reports 2.1.0, the native Claude
+> plugin commands at the top of this README remain available.
### Pick one path only (per harness)
You can use ECC with Claude Code, Codex, and other harnesses at the same time. Choose one install method for each harness:
-- **Recommended today for Claude Code:** use the [native plugin commands above](#install-with-claude-code)
-- **Coming in release 2.2:** guided package setup for Claude Code, Codex, and Kimi Code; see the preview at the bottom of this install area
+- **Recommended default:** run the guided Claude plugin setup below once `npm view ecc-universal version` reports 2.2.0
+- **Available throughout npm propagation:** use the [native plugin commands above](#install-with-claude-code)
+- **Available in release 2.2:** guided package setup for Claude Code, Codex, and Kimi Code
- **Works:** Claude Code plugin + Codex native plugin
- **Works:** Claude Code plugin + the legacy Codex sync flow
- **Avoid:** Claude Code plugin + full Claude manual install
@@ -191,7 +193,7 @@ If you already layered multiple installs and things look duplicated, skip straig
### Claude Code details
-Claude Code owns these built-in commands, including their errors when a marketplace, plugin, or conflicting scope already exists. ECC cannot intercept that parser. If either native command reports an existing install or scope conflict, wait for the 2.2.0 guided setup or resolve the conflicting Claude plugin scope before retrying; do not layer a manual install on top.
+Claude Code owns these built-in commands, including their errors when a marketplace, plugin, or conflicting scope already exists. ECC cannot intercept that parser. If either native command reports an existing install or scope conflict, use the 2.2 guided setup or resolve the conflicting Claude plugin scope before retrying; do not layer a manual install on top.
After ECC is installed, `/ecc:configure-ecc` is the namespaced in-Claude reconfiguration skill. It delegates to the same safe setup flow, but it is available only after the plugin is installed and cannot replace Claude Code's built-in `/plugin` command during a first install.
@@ -587,13 +589,12 @@ If you stacked methods, clean up in this order:
4. Reinstall once, using a single path.
-## Coming soon: guided setup in release 2.2
+## Guided package setup in release 2.2
-> [!WARNING]
-> These ECC package-runner commands are not available in the current npm
-> release, 2.1.0. Do not run them until `ecc-universal` 2.2.0 is published.
-
-The earlier README description—**Recommended default:** run the guided Claude plugin setup—was published too soon. That recommendation is withdrawn until release 2.2.
+> [!IMPORTANT]
+> These package-runner commands require `ecc-universal` 2.2.0 or newer.
+> Confirm registry propagation with `npm view ecc-universal version`. The
+> native Claude plugin install remains available throughout npm rollout.
For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
@@ -601,7 +602,7 @@ For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
npx ecc-universal setup
```
-Release 2.2 will support the same guided setup through modern package runners:
+ECC 2.2 supports the same guided setup through modern package runners:
| Package runner | Guided setup command |
|---|---|
@@ -610,7 +611,7 @@ Release 2.2 will support the same guided setup through modern package runners:
| Yarn 2+ | `yarn dlx ecc-universal setup` |
| Bun | `bunx ecc-universal setup` |
-Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run after 2.2 is published.
+Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run.
The wizard inventories the official marketplace and every native Claude install scope before making changes, then installs, updates, or safely moves `ecc@ecc` to the scope you choose. Rerun the same command whenever you want to update ECC, change scope, or change its hook profile. This setup wizard currently configures the Claude Code plugin; use the multi-harness wizard below for Codex or Kimi Code.
@@ -644,7 +645,7 @@ npx ecc-universal install --guided --harness codex --dry-run
npx ecc-universal install --profile core --target kimi --dry-run
```
-Additional package-name commands will also become available through the 2.2 alias:
+Additional package-name commands are also available through the 2.2 alias:
```bash
npx ecc-universal consult "security reviews" --target claude
diff --git a/docs/ANTIGRAVITY-GUIDE.md b/docs/ANTIGRAVITY-GUIDE.md
index b2ca2e874..998915216 100644
--- a/docs/ANTIGRAVITY-GUIDE.md
+++ b/docs/ANTIGRAVITY-GUIDE.md
@@ -8,16 +8,18 @@ Native Antigravity 2.0 installation requires ECC 2.2.0 or newer. ECC 2.1.0 uses
the legacy `.agent/` adapter and does not provide the native layout described
below.
-> [!IMPORTANT]
-> **Temporary release status:** npm latest is currently `ecc-universal@2.1.0`.
-> ECC 2.2.0 has not been published to npm yet. Until it is published, use a
-> current source checkout of `main` for native `.agents` support or wait for the
-> release.
-
-
-
## Quick start
+Verify that 2.2.0 is readable from the registry, then run the pinned package
+from the project you want to configure:
+
+```bash
+npm view ecc-universal version
+npx ecc-universal@2.2.0 install --profile minimal --target antigravity
+```
+
+### Source checkout alternative
+
```bash
# Run every command below from the project you want to configure.
# Keep the ECC source checkout separate and use its absolute path.
diff --git a/docs/releases/2.2.0/launch-runbook.md b/docs/releases/2.2.0/launch-runbook.md
new file mode 100644
index 000000000..a6282eb23
--- /dev/null
+++ b/docs/releases/2.2.0/launch-runbook.md
@@ -0,0 +1,133 @@
+# ECC 2.2 launch and rollback runbook
+
+Affaan is the only release operator for ECC 2.2. Everyone else may prepare,
+review, and verify the release candidate, but must not merge the release PR,
+create or push `v2.2.0`, change npm dist-tags, or publish the GitHub Release.
+
+## Availability model
+
+The default npm install remains `ecc-universal@2.1.0` until the final promotion
+step succeeds. The release workflow publishes 2.2.0 under the `staged` tag,
+reads its registry integrity back, compares those bytes with the exact archive
+that passed the three-platform lifecycle, and only then moves `latest` to
+2.2.0. There is no interval where `latest` points at an unpublished version.
+
+The native Claude marketplace install remains an independent install path
+throughout the npm rollout:
+
+```text
+/plugin marketplace add https://github.com/affaan-m/ECC
+/plugin install ecc@ecc
+```
+
+Never unpublish 2.1.0 or 2.2.0. npm dist-tags provide the reversible switch.
+
+## Current fallback baseline
+
+Before merge, confirm all of these:
+
+```bash
+npm view ecc-universal dist-tags --json
+npm view ecc-universal@2.1.0 dist.integrity
+curl -fsSIL https://registry.npmjs.org/ecc-universal/-/ecc-universal-2.1.0.tgz
+gh release view v2.1.0 --repo affaan-m/ECC
+```
+
+Expected:
+
+- `latest` is `2.1.0`.
+- The 2.1.0 tarball returns HTTP 200 and immutable caching headers.
+- A clean `npm install ecc-universal@2.1.0` succeeds.
+- A disposable managed install and uninstall succeed.
+
+The published 2.1 Cursor adapter can report one non-blocking doctor warning for
+an adapted Markdown link. This does not prevent installation or uninstall. ECC
+2.2 corrects the packed lifecycle and doctor behavior.
+
+## Preflight before Affaan merges
+
+1. PR #2863 must be mergeable and all required hosted checks must pass.
+2. The full local suite, npm audit, IOC scan, and exact packed lifecycle must
+ pass at the PR head.
+3. The packed README must describe 2.2 as available and contain no unpublished
+ 2.2 warning.
+4. The Nasiko surface must say experimental CLI lifecycle bridge.
+5. `npm view ecc-universal@2.2.0 version` must return E404. Any other registry
+ error blocks the release.
+6. `npm view ecc-universal dist-tags --json` must still show `latest: 2.1.0`.
+
+## The release switch
+
+After Affaan merges PR #2863, wait for CI on the exact `origin/main` commit.
+From a clean, current `main` checkout:
+
+```bash
+git fetch origin main --tags
+git switch main
+git pull --ff-only origin main
+git status --short
+git rev-parse HEAD
+git rev-parse origin/main
+```
+
+The two commit IDs must match and `git status --short` must print nothing.
+Affaan then creates and pushes the signed release tag:
+
+```bash
+git tag -s v2.2.0 -m "ECC 2.2.0" HEAD
+git tag -v v2.2.0
+git push origin refs/tags/v2.2.0
+```
+
+That tag push is the only launch switch. The workflow then:
+
+1. Requires the tag commit to equal `origin/main`.
+2. Packs and hashes the npm archive once.
+3. Runs the exact archive on Linux, macOS, and Windows.
+4. Publishes the archive to the npm `staged` tag.
+5. Reads back and verifies registry integrity.
+6. Atomically promotes the verified version to `latest`.
+7. Creates the GitHub Release from the reviewed notes.
+
+## Immediate canary
+
+After the workflow succeeds:
+
+```bash
+npm view ecc-universal dist-tags --json
+npm view ecc-universal@2.2.0 version dist.integrity
+gh release view v2.2.0 --repo affaan-m/ECC
+npx --yes ecc-universal@2.2.0 setup --help
+npx --yes ecc-universal@latest setup --help
+```
+
+Expected:
+
+- Both exact-version and `latest` resolve to 2.2.0.
+- Registry integrity matches the workflow output.
+- The GitHub Release exists and uses the reviewed notes.
+- Both package invocations return the guided setup help.
+- The native Claude marketplace remains installable.
+
+Keep watching npm and GitHub install paths during the launch window. Treat an
+HTTP failure, integrity mismatch, missing public binary, or failed disposable
+install as critical.
+
+## Rollback
+
+If 2.2.0 has an install-critical regression, Affaan or another authorized npm
+owner restores the known installable fallback immediately:
+
+```bash
+npm dist-tag add ecc-universal@2.1.0 latest
+npm view ecc-universal dist-tags --json
+ECC_ROLLBACK_ROOT=$(mktemp -d)
+npm install --ignore-scripts --prefix "$ECC_ROLLBACK_ROOT" ecc-universal@2.1.0
+node "$ECC_ROLLBACK_ROOT/node_modules/ecc-universal/scripts/ecc.js" --help
+gh release edit v2.1.0 --repo affaan-m/ECC --latest
+```
+
+Then open a release incident, state that 2.2.0 remains available only by exact
+version while the incident is investigated, and repair forward with a new patch
+version. Do not unpublish either package version and do not reuse the `v2.2.0`
+tag.
diff --git a/docs/releases/2.2.0/release-notes.md b/docs/releases/2.2.0/release-notes.md
index b415dd1cd..6aa336ddf 100644
--- a/docs/releases/2.2.0/release-notes.md
+++ b/docs/releases/2.2.0/release-notes.md
@@ -8,14 +8,14 @@ ECC 2.2.0 makes the universal installer a first-class, cross-harness distributio
- Repeated selective installs retain the complete managed ownership ledger. A later module install no longer causes previously installed ECC files to survive uninstall.
- OpenCode home installs use `~/.config/opencode`. Reinstall or repair discovers legacy `~/.opencode` ownership, migrates unchanged ECC-managed files, and preserves modified files for review. Bundled agent definitions inherit the user's selected model provider.
- Legacy Codex sync cleanup requires ownership evidence by default and preserves untracked or modified user files.
-- Nasiko lifecycle locks recover only when their recorded owner is confirmed dead. Its pinned archive parser rejects malformed boundaries, and incomplete uninstall cleanup returns an error with retained-file guidance.
+- The experimental Nasiko CLI lifecycle bridge recovers locks only when their recorded owner is confirmed dead. Its pinned archive parser rejects malformed boundaries, and incomplete uninstall cleanup returns an error with retained-file guidance. ECC does not connect or operate a Nasiko control plane, enable telemetry, or provide a supported end-to-end Nasiko workflow.
- `skill-comply` is included in both the install graph and npm archive. Python bytecode and pytest caches remain excluded.
## New capabilities
- Guided multi-harness setup and stronger doctor, repair, status, and uninstall flows.
- Native Antigravity 2.0 documentation for Bash and PowerShell.
-- Expanded Itô, Nasiko, agent-evaluation, multi-model council, dev-team, living-docs, secure terminal, Pi, and TasteForge workflows.
+- Expanded Itô, agent-evaluation, multi-model council, dev-team, living-docs, secure terminal, Pi, and TasteForge workflows, plus the experimental Nasiko CLI lifecycle bridge.
- Improved Plan Canvas, memory vault, continuous learning, skill evolution, hook stability, session handling, and Discord delivery.
## Release assurance
@@ -23,7 +23,8 @@ ECC 2.2.0 makes the universal installer a first-class, cross-harness distributio
- The release workflow requires the tagged commit to equal `origin/main` exactly.
- npm registry failures stop the release instead of being treated as an unpublished version.
- The exact packed archive is hashed once and exercised on Linux, macOS, and Windows before publication.
-- The verified npm archive is published before the matching GitHub Release is created. A retry verifies byte-for-byte registry integrity.
+- Stable npm releases publish first to a staging dist-tag, verify byte-for-byte registry integrity, and only then promote `latest`. The matching GitHub Release is created after promotion.
+- The prior 2.1.0 package remains immutable and installable as the immediate dist-tag rollback target.
## Upgrade
diff --git a/docs/testing/ecc-2.2-release-readiness.tdd.md b/docs/testing/ecc-2.2-release-readiness.tdd.md
index e7bb60244..6c9ad203e 100644
--- a/docs/testing/ecc-2.2-release-readiness.tdd.md
+++ b/docs/testing/ecc-2.2-release-readiness.tdd.md
@@ -4,7 +4,7 @@ Date: 2026-08-25
## Scope
-This pass covers the release blockers found in the delta from `v2.1.0`: cumulative selective-install ownership, native Antigravity packaging, canonical OpenCode installation and conservative legacy migration, provider-neutral OpenCode agents, `skill-comply` distribution, conservative legacy Codex uninstall, release-workflow safety, and guided-install filesystem boundaries.
+This pass covers the release blockers found in the delta from `v2.1.0`: cumulative selective-install ownership, native Antigravity packaging, canonical OpenCode installation and conservative legacy migration, provider-neutral OpenCode agents, `skill-comply` distribution, conservative legacy Codex uninstall, release-workflow safety, guided-install filesystem boundaries, npm availability during promotion, and accurate Nasiko release boundaries.
## RED
@@ -37,18 +37,35 @@ Commit `85673326` added legacy OpenCode regressions for custom configuration roo
Commit `5aa66021` moved ambient-override checks into isolated child processes and added a regression requiring invocation environments to be immutable snapshots. The snapshot assertion failed before the environment-copy repair.
+The final independent audit found a recovery race in legacy OpenCode cleanup: a
+clobbering rename could overwrite a user file created after quarantine. A
+deterministic injected-filesystem regression now proves recovery fails closed,
+keeps the new user file, and retains the old managed file in quarantine.
+
+The same audit found prerelease wording in the immutable npm README, temporary
+Antigravity guidance, and wording that overstated the Nasiko feature. Focused
+copy regressions now reject those stale statements and require the implemented
+surface to be described as an experimental Nasiko CLI lifecycle bridge.
+
## GREEN
- Focused installer, lifecycle, packaging, release-workflow, manifest, OpenCode, Antigravity, and uninstall tests passed.
-- Full repository suite: 3,987 passed, 0 failed.
-- `npm audit --audit-level=low`: 0 vulnerabilities.
+- Full repository suite: 3,992 passed, 0 failed.
+- `npm audit --audit-level=high`: 0 vulnerabilities.
- Supply-chain IOC scan: 207 files inspected, no findings.
- Both release workflow YAML files parsed successfully.
- Both release workflows derive reviewed notes from the validated tag and fail clearly when that version's notes are absent.
- Release-note selection follows the lowercase filename convention shared by prior release directories.
-- Exact packed archive lifecycle passed on macOS with Node 24.9.0 using SHA-256 `cf3a5ccefda2608389c7039b6c8b7f5707fd3fdd99579e843ed1aa593c7b1a15`.
+- Exact packed archive lifecycle passed on macOS with Node 24.9.0 using SHA-256 `019547d032e63ee169abb2f92695dee25d6e60ed64c4085142225d75fb7a76c8`.
- The packed lifecycle covered npm installation, public CLI setup, cumulative Cursor install, drift detection, repair, uninstall, user-file preservation, Antigravity install/doctor/uninstall, and OpenCode install/doctor/uninstall.
- Simulated hosted-runner `OPENCODE_CONFIG_DIR` and `XDG_CONFIG_HOME` overrides passed the adapter, MCP inventory, lifecycle, legacy migration, doctor, repair, list, and uninstall suites while explicit CLI environments continued to honor those overrides.
+- The stable workflow publishes 2.2.0 to `staged`, verifies the public registry
+ SHA-512 against the exact tested archive, and only then promotes `latest`.
+- The live npm `latest` tag remained on 2.1.0. A clean exact 2.1.0 package
+ install and disposable Cursor install/uninstall passed, and its tarball
+ remained publicly readable with immutable caching.
+- A launch and rollback runbook assigns the merge, signed tag, and release to
+ Affaan and uses the npm dist-tag as the reversible availability switch.
## Focused coverage
diff --git a/manifests/install-components.json b/manifests/install-components.json
index 971f86607..7c6c9ee8c 100644
--- a/manifests/install-components.json
+++ b/manifests/install-components.json
@@ -205,7 +205,7 @@
{
"id": "capability:nasiko-control-plane",
"family": "capability",
- "description": "Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.",
+ "description": "Experimental Nasiko CLI lifecycle bridge guidance for pinned installation, read-only status, qualified uninstall, and opt-in telemetry boundaries.",
"modules": [
"nasiko-control-plane"
]
diff --git a/manifests/install-modules.json b/manifests/install-modules.json
index 992e9193d..a0cda838f 100644
--- a/manifests/install-modules.json
+++ b/manifests/install-modules.json
@@ -639,7 +639,7 @@
{
"id": "nasiko-control-plane",
"kind": "skills",
- "description": "Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.",
+ "description": "Experimental Nasiko CLI lifecycle bridge guidance for pinned installation, read-only status, qualified uninstall, and opt-in telemetry boundaries.",
"paths": [
"skills/nasiko-control-plane"
],
diff --git a/scripts/ecc.js b/scripts/ecc.js
index 8a92fa302..6c2aee1a5 100755
--- a/scripts/ecc.js
+++ b/scripts/ecc.js
@@ -41,7 +41,7 @@ const COMMANDS = {
},
nasiko: {
script: 'nasiko.js',
- description: 'Install or inspect the optional pinned Nasiko control-plane CLI',
+ description: 'Install or inspect the optional pinned Nasiko CLI lifecycle bridge',
},
memory: {
script: 'memory.js',
diff --git a/scripts/lib/install/opencode-legacy-migration.js b/scripts/lib/install/opencode-legacy-migration.js
index 5b79faab5..baf3472f1 100644
--- a/scripts/lib/install/opencode-legacy-migration.js
+++ b/scripts/lib/install/opencode-legacy-migration.js
@@ -205,42 +205,79 @@ function verifyManagedLegacyFile(operation, location, sourceRoot) {
return { destinationPath, stat: destination.stat };
}
-function removeVerifiedLegacyFile(entry, location) {
+function pathExistsWith(fileSystem, filePath) {
+ try {
+ fileSystem.lstatSync(filePath);
+ return true;
+ } catch (error) {
+ if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
+ return false;
+ }
+ throw error;
+ }
+}
+
+function restoreQuarantinedFileNoClobber(quarantinePath, safePath, fileSystem) {
+ try {
+ fileSystem.linkSync(quarantinePath, safePath);
+ } catch (error) {
+ error.retainedPath = quarantinePath;
+ throw error;
+ }
+ try {
+ fileSystem.rmSync(quarantinePath);
+ } catch (error) {
+ error.retainedPath = quarantinePath;
+ throw error;
+ }
+}
+
+function removeVerifiedLegacyFile(entry, location, fileSystem = fs) {
const safePath = assertWithinTrustedRoot(
entry.destinationPath,
location.targetRoot,
'remove verified legacy OpenCode file'
);
- const quarantineDir = fs.mkdtempSync(path.join(
+ const quarantineDir = fileSystem.mkdtempSync(path.join(
path.dirname(location.targetRoot),
'.ecc-opencode-remove-'
));
const quarantinePath = path.join(quarantineDir, path.basename(safePath));
try {
- fs.renameSync(safePath, quarantinePath);
- const quarantinedStat = fs.lstatSync(quarantinePath, { bigint: true });
+ fileSystem.renameSync(safePath, quarantinePath);
+ const quarantinedStat = fileSystem.lstatSync(quarantinePath, { bigint: true });
const identityMatches = !quarantinedStat.isSymbolicLink()
&& quarantinedStat.isFile()
&& quarantinedStat.dev === entry.stat.dev
&& quarantinedStat.ino === entry.stat.ino;
if (!identityMatches) {
- fs.renameSync(quarantinePath, safePath);
- fs.rmdirSync(quarantineDir);
- return false;
+ const identityError = new Error(
+ `Legacy OpenCode file changed during quarantine: ${safePath}`
+ );
+ identityError.code = 'ESTALE';
+ throw identityError;
}
- fs.rmSync(quarantinePath);
- fs.rmdirSync(quarantineDir);
+ fileSystem.rmSync(quarantinePath);
+ fileSystem.rmdirSync(quarantineDir);
return true;
} catch (error) {
+ let restoreError = null;
try {
- if (pathExists(quarantinePath) && !pathExists(safePath)) {
- fs.renameSync(quarantinePath, safePath);
+ if (pathExistsWith(fileSystem, quarantinePath)) {
+ restoreQuarantinedFileNoClobber(quarantinePath, safePath, fileSystem);
}
- if (pathExists(quarantineDir) && fs.readdirSync(quarantineDir).length === 0) {
- fs.rmdirSync(quarantineDir);
+ if (
+ pathExistsWith(fileSystem, quarantineDir)
+ && fileSystem.readdirSync(quarantineDir).length === 0
+ ) {
+ fileSystem.rmdirSync(quarantineDir);
}
- } catch (_restoreError) {
- // Preserve the quarantined entry when restoration cannot be proven safe.
+ } catch (recoveryError) {
+ restoreError = recoveryError;
+ }
+ if (restoreError) {
+ restoreError.cause = error;
+ throw restoreError;
}
throw error;
}
@@ -294,8 +331,9 @@ function removeLegacyFiles(removable, location, retainedPaths) {
}
removedPaths.push(entry.destinationPath);
removeEmptyParents(entry.destinationPath, location.targetRoot);
- } catch (_error) {
+ } catch (error) {
retainedPaths.push(entry.destinationPath);
+ if (error.retainedPath) retainedPaths.push(error.retainedPath);
}
}
return removedPaths;
@@ -356,4 +394,5 @@ module.exports = {
cleanupLegacyOpencodeInstall,
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
+ removeVerifiedLegacyFile,
};
diff --git a/scripts/nasiko.js b/scripts/nasiko.js
index 27c9c5ddf..71a240878 100644
--- a/scripts/nasiko.js
+++ b/scripts/nasiko.js
@@ -13,7 +13,7 @@ const {
function helpText() {
return `
-ECC Nasiko control-plane bridge
+ECC experimental Nasiko CLI lifecycle bridge
Usage:
ecc nasiko status [--install-dir
] [--json]
diff --git a/skills/nasiko-control-plane/SKILL.md b/skills/nasiko-control-plane/SKILL.md
index bb95391d7..43a9c50d4 100644
--- a/skills/nasiko-control-plane/SKILL.md
+++ b/skills/nasiko-control-plane/SKILL.md
@@ -1,12 +1,12 @@
---
name: nasiko-control-plane
-description: Install, detect, and operate the optional Nasiko agent control plane through ECC with pinned artifacts, explicit consent, and telemetry and secrets boundaries.
+description: Use the experimental Nasiko CLI lifecycle bridge for pinned installation, read-only status, and qualified uninstall with explicit consent and telemetry and secrets boundaries.
---
-# Nasiko Control Plane
+# Nasiko CLI Lifecycle Bridge
-Use this skill when a user explicitly asks to install, inspect, or operate the
-Nasiko control plane with ECC.
+Use this skill when a user explicitly asks ECC to install, inspect, or remove
+the qualified Nasiko CLI. This skill does not operate a Nasiko control plane.
## Safety contract
diff --git a/skills/nasiko-control-plane/agents/openai.yaml b/skills/nasiko-control-plane/agents/openai.yaml
index 6168412b7..25b26155f 100644
--- a/skills/nasiko-control-plane/agents/openai.yaml
+++ b/skills/nasiko-control-plane/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
- display_name: "Nasiko Control Plane"
- short_description: "Safely install and inspect the optional Nasiko control plane"
+ display_name: "Nasiko CLI Bridge"
+ short_description: "Safely install and inspect the optional pinned Nasiko CLI"
default_prompt: "Use $nasiko-control-plane to inspect or explicitly install the pinned Nasiko CLI without enabling telemetry or exposing secrets."
diff --git a/tests/ci/nasiko-control-plane.test.js b/tests/ci/nasiko-control-plane.test.js
index 53b67b990..ad68cec60 100644
--- a/tests/ci/nasiko-control-plane.test.js
+++ b/tests/ci/nasiko-control-plane.test.js
@@ -1,5 +1,5 @@
/**
- * Contract and lifecycle tests for the opt-in Nasiko control-plane bridge.
+ * Contract and lifecycle tests for the opt-in Nasiko CLI lifecycle bridge.
*/
const assert = require('assert');
@@ -59,7 +59,7 @@ function tarGzipFixture({
}
async function main() {
- console.log('\n=== Testing Nasiko control-plane integration ===\n');
+ console.log('\n=== Testing Nasiko CLI lifecycle bridge ===\n');
const tests = [
['qualifies only pinned platform releases and rejects latest', () => {
@@ -468,7 +468,7 @@ async function main() {
{
id: 'capability:nasiko-control-plane',
family: 'capability',
- description: 'Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.',
+ description: 'Experimental Nasiko CLI lifecycle bridge guidance for pinned installation, read-only status, qualified uninstall, and opt-in telemetry boundaries.',
modules: ['nasiko-control-plane'],
}
);
diff --git a/tests/docs/antigravity-guide.test.js b/tests/docs/antigravity-guide.test.js
index 6640a7a08..2610f24fa 100644
--- a/tests/docs/antigravity-guide.test.js
+++ b/tests/docs/antigravity-guide.test.js
@@ -33,22 +33,22 @@ test('guide requires an installer with native Antigravity 2.0 support', () => {
);
});
-test('guide states the temporary npm release boundary', () => {
+test('guide uses the published 2.2 package without stale pre-release copy', () => {
assert.ok(
- guide.includes('npm latest is currently `ecc-universal@2.1.0`'),
- 'Guide should identify the package version users receive from npm today'
+ guide.includes('npm view ecc-universal version'),
+ 'Guide should let operators verify registry propagation before installation'
);
assert.ok(
- guide.includes('ECC 2.2.0 has not been published to npm yet'),
- 'Guide should not imply that native Antigravity support is already published'
+ guide.includes('npx ecc-universal@2.2.0 install --profile minimal --target antigravity'),
+ 'Guide should provide the pinned published-package installation path'
);
assert.ok(
- guide.includes('current source checkout of `main` for native `.agents` support'),
- 'Guide should direct users to the main source checkout until ECC 2.2.0 is published'
+ !guide.includes('ECC 2.2.0 has not been published to npm yet'),
+ 'The immutable 2.2 guide must not claim that 2.2 is unpublished'
);
assert.ok(
- guide.includes('remove this release-status paragraph only after `ecc-universal@2.2.0` is published and registry readback succeeds'),
- 'Guide should retain a removal condition for the temporary release warning'
+ !guide.includes('npm latest is currently `ecc-universal@2.1.0`'),
+ 'The immutable 2.2 guide must not advertise the old latest version'
);
});
diff --git a/tests/docs/release-2.2-copy.test.js b/tests/docs/release-2.2-copy.test.js
new file mode 100644
index 000000000..e4255b3f4
--- /dev/null
+++ b/tests/docs/release-2.2-copy.test.js
@@ -0,0 +1,40 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const repoRoot = path.resolve(__dirname, '..', '..');
+
+function read(relativePath) {
+ return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
+}
+
+const readme = read('README.md');
+const changelog = read('CHANGELOG.md');
+const releaseNotes = read('docs/releases/2.2.0/release-notes.md');
+const nasikoSkill = read('skills/nasiko-control-plane/SKILL.md');
+const modules = read('manifests/install-modules.json');
+const components = read('manifests/install-components.json');
+const staleReleaseCopy = [
+ /guided package setup is coming in .*2\.2/i,
+ /current npm\s+release,?\s+2\.1\.0/i,
+ /until .*2\.2\.0 is published/i,
+ /coming soon: guided setup in release 2\.2/i,
+ /release 2\.2 will support/i,
+];
+
+for (const pattern of staleReleaseCopy) {
+ assert.doesNotMatch(readme, pattern);
+}
+
+assert.match(readme, /ECC 2\.2 includes guided package setup/i);
+assert.match(readme, /npm view ecc-universal version/);
+
+for (const source of [changelog, releaseNotes, nasikoSkill, modules, components]) {
+ assert.doesNotMatch(source, /Nasiko integration/i);
+ assert.doesNotMatch(source, /operate the optional Nasiko agent control plane/i);
+ assert.match(source, /Nasiko CLI lifecycle bridge/i);
+}
+
+console.log('ECC 2.2 release copy: ok');
diff --git a/tests/docs/release-2.2-launch-runbook.test.js b/tests/docs/release-2.2-launch-runbook.test.js
new file mode 100644
index 000000000..af87988a0
--- /dev/null
+++ b/tests/docs/release-2.2-launch-runbook.test.js
@@ -0,0 +1,22 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const runbook = fs.readFileSync(
+ path.resolve(__dirname, '..', '..', 'docs', 'releases', '2.2.0', 'launch-runbook.md'),
+ 'utf8'
+);
+
+assert.match(runbook, /Affaan.*only release operator/i);
+assert.match(runbook, /npm view ecc-universal dist-tags --json/);
+assert.match(runbook, /ecc-universal@2\.1\.0/);
+assert.match(runbook, /git tag -s v2\.2\.0/);
+assert.match(runbook, /git push origin refs\/tags\/v2\.2\.0/);
+assert.match(runbook, /npm dist-tag add ecc-universal@2\.1\.0 latest/);
+assert.match(runbook, /staged.*registry.*latest/is);
+assert.match(runbook, /do not unpublish/i);
+assert.match(runbook, /rollback/i);
+
+console.log('ECC 2.2 launch runbook: ok');
diff --git a/tests/lib/opencode-legacy-migration.test.js b/tests/lib/opencode-legacy-migration.test.js
index ab4c95ca6..df7564e8e 100644
--- a/tests/lib/opencode-legacy-migration.test.js
+++ b/tests/lib/opencode-legacy-migration.test.js
@@ -19,6 +19,7 @@ const {
cleanupLegacyOpencodeInstall,
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
+ removeVerifiedLegacyFile,
} = require('../../scripts/lib/install/opencode-legacy-migration');
const REPO_ROOT = path.join(__dirname, '..', '..');
@@ -295,5 +296,69 @@ test('migration never follows a legacy managed-file symlink', () => {
}
});
+test('legacy cleanup never overwrites a file created during quarantine recovery', () => {
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-no-clobber-'));
+ const targetRoot = path.join(homeDir, '.opencode');
+ const destinationPath = path.join(targetRoot, 'managed.md');
+ let quarantinePath = null;
+ let effectiveSafePath = destinationPath;
+ try {
+ fs.mkdirSync(targetRoot, { recursive: true });
+ fs.writeFileSync(destinationPath, 'managed-old\n');
+ const originalStat = fs.lstatSync(destinationPath, { bigint: true });
+ let injected = false;
+ const fileSystem = new Proxy(fs, {
+ get(target, property) {
+ if (property === 'renameSync') {
+ return (sourcePath, targetPath) => {
+ fs.renameSync(sourcePath, targetPath);
+ effectiveSafePath = sourcePath;
+ quarantinePath = targetPath;
+ };
+ }
+ if (property === 'lstatSync') {
+ return (filePath, options) => {
+ const stat = fs.lstatSync(filePath, options);
+ if (!injected && quarantinePath && filePath === quarantinePath) {
+ injected = true;
+ fs.writeFileSync(effectiveSafePath, 'user-new\n', { flag: 'wx' });
+ return new Proxy(stat, {
+ get(statTarget, statProperty) {
+ if (statProperty === 'ino') return statTarget.ino + 1n;
+ const value = Reflect.get(statTarget, statProperty, statTarget);
+ return typeof value === 'function' ? value.bind(statTarget) : value;
+ },
+ });
+ }
+ return stat;
+ };
+ }
+ const value = Reflect.get(target, property, target);
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+ });
+
+ assert.throws(
+ () => removeVerifiedLegacyFile(
+ { destinationPath, stat: originalStat },
+ { targetRoot },
+ fileSystem
+ ),
+ error => {
+ assert.strictEqual(error.code, 'EEXIST');
+ assert.strictEqual(error.retainedPath, quarantinePath);
+ return true;
+ }
+ );
+ assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'user-new\n');
+ assert.strictEqual(fs.readFileSync(quarantinePath, 'utf8'), 'managed-old\n');
+ } finally {
+ fs.rmSync(homeDir, { recursive: true, force: true });
+ if (quarantinePath) {
+ fs.rmSync(path.dirname(quarantinePath), { recursive: true, force: true });
+ }
+ }
+});
+
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
diff --git a/tests/scripts/release-publish.test.js b/tests/scripts/release-publish.test.js
index 5127e565d..1b68a122f 100644
--- a/tests/scripts/release-publish.test.js
+++ b/tests/scripts/release-publish.test.js
@@ -77,6 +77,27 @@ for (const workflow of [
assert.match(content, /NODE_AUTH_TOKEN:\s*\$\{\{\s*secrets\.NPM_TOKEN\s*\}\}/);
});
+ test(`${workflow} stages stable npm versions before changing latest`, () => {
+ assert.match(content, /publish_tag:\s*\$\{\{ steps\.npm_publish_state\.outputs\.publish_tag \}\}/);
+ assert.match(content, /version\.includes\('-'\) \? 'next' : 'staged'/);
+ assert.match(content, /--tag "\$\{NPM_PUBLISH_TAG\}"/);
+ assert.match(content, /npm dist-tag add "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" "\$\{NPM_DIST_TAG\}"/);
+ });
+
+ test(`${workflow} verifies registry bytes before promoting the final dist-tag`, () => {
+ const publishIndex = content.indexOf('name: Publish npm package');
+ const verifyIndex = content.indexOf('name: Verify published npm artifact');
+ const promoteIndex = content.indexOf('name: Promote verified npm version');
+ const releaseIndex = content.indexOf('name: Create GitHub Release');
+
+ assert.ok(publishIndex >= 0, 'missing npm publish step');
+ assert.ok(verifyIndex > publishIndex, 'registry verification must follow npm publish');
+ assert.ok(promoteIndex > verifyIndex, 'dist-tag promotion must follow registry verification');
+ assert.ok(releaseIndex > promoteIndex, 'GitHub Release must follow npm promotion');
+ assert.match(content, /npm view "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" dist\.integrity/);
+ assert.match(content, /Published npm artifact does not match tested candidate/);
+ });
+
test(`${workflow} publishes to npm before creating the GitHub Release`, () => {
const releaseIndex = content.indexOf('name: Create GitHub Release');
const publishIndex = content.indexOf('name: Publish npm package');