From e140a95255e186ed3df2ef6672b4e535fe6c08dd Mon Sep 17 00:00:00 2001 From: SkyFi Geek <45924209+mobileskyfi@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:21:31 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(descriptor):=20prefer=20plain=20forward?= =?UTF-8?q?s=20=E2=80=94=20stock-CHR=20TLS=20endpoints=20are=20not=20diala?= =?UTF-8?q?ble=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descriptor v1 shipped secure-preferred service endpoints, but on a stock CHR `www-ssl` is disabled and `api-ssl` is certificate-less (TLS alert 40, grounded on CHR 7.23.2), so `services["rest-api"]`/`["native-api"]` advertised dead endpoints — surfaced by the first real consumer (tikoci/centrs#134 `--quickchr` CHR acceptance). Pick `http`/`api` (the ports `restUrl` has always used), falling back to `https`/`api-ssl` with `tls: true` only when the plain forward is excluded; the excludePorts latent-bug fix is preserved. Co-Authored-By: Claude Fable 5 (1M context) --- CHANGELOG.md | 11 +++++++++++ docs/centrs-interface.md | 19 +++++++++++-------- src/lib/quickchr.ts | 18 ++++++++++++------ test/unit/cli-env-inspect.test.ts | 6 ++++-- test/unit/descriptor.test.ts | 17 ++++++++++------- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6266d8c..1c1796f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ Even minor versions (0.2.x, 0.4.x) are releases; odd minors (0.3.x, 0.5.x) are p ## [Unreleased] +### Fixed + +- **Descriptor v1 now advertises the plain forwards (plain-first), not the TLS ones** + (issue #95). 0.4.4's secure-preferred order pointed `services["rest-api"]` / + `services["native-api"]` at endpoints that are not dialable on a stock CHR: + `www-ssl` is disabled by default and `api-ssl` is certificate-less (TLS handshake + alert 40; grounded on CHR 7.23.2). The descriptor now picks `http`/`api` — the same + ports `restUrl` has always used — falling back to `https`/`api-ssl` (with + `tls: true`) only when the plain forward is excluded. First surfaced by the first + real consumer, `tikoci/centrs#134` `--quickchr` CHR acceptance. + ## [0.4.4] — 2026-07-18 ### Added diff --git a/docs/centrs-interface.md b/docs/centrs-interface.md index 5af5f6b..2baa93d 100644 --- a/docs/centrs-interface.md +++ b/docs/centrs-interface.md @@ -282,14 +282,17 @@ quickchr already tracks plain/TLS pairs separately (`ChrPorts.http`/`https`, - `services["native-api"]` → pick `apiSsl`/`api`; set `tls` to match. - `services.ssh` → always `tls: false`. -**Implementation note:** there was no pre-existing "REST-URL preference logic" to reuse -here — before this issue, `restUrl` (used for every REST call, and for the old flat -descriptor's `urls.http`/`rest`/`restBase`) was unconditionally built from `ports.http`; -`https`/`apiSsl` were just additive fields, never preferred. The secure-preferred, -plain-fallback logic above was written fresh as part of implementing this contract. As a -side effect it also closes a latent bug: `excludePorts` had no guard against excluding -`"http"` while keeping `"https"`, which previously left the REST base pointing at a port -that didn't exist — `services["rest-api"]` now falls back to the surviving port instead. +**Implementation note (revised in #95):** the descriptor is **plain-first**: +`services["rest-api"]` picks `ports.http` (falling back to `https`), and +`services["native-api"]` picks `ports.api` (falling back to `api-ssl`), matching the +unconditional `ports.http` base `restUrl` has always used. The 0.4.4 release briefly +shipped the opposite (secure-preferred) order, which advertised endpoints that are +**not dialable on a stock CHR** — `www-ssl` is disabled by default and `api-ssl` is +certificate-less (TLS alert 40), grounded on CHR 7.23.2 in #95. Secure preference can +return once boot provisioning installs a certificate and enables `www-ssl`. The +fallback still closes the `excludePorts` latent bug: excluding `"http"` while keeping +`"https"` resolves `services["rest-api"]` onto the surviving secure port (`tls: true`) +instead of a port that doesn't exist. `services["rest-api"].url` includes the `/rest` path suffix (e.g. `https://127.0.0.1:19101/rest`) since that's the actual base a consumer dials for diff --git a/src/lib/quickchr.ts b/src/lib/quickchr.ts index 89f32ee..78604c6 100644 --- a/src/lib/quickchr.ts +++ b/src/lib/quickchr.ts @@ -286,7 +286,14 @@ function createInstance(state: MachineState): ChrInstance { authObj: { username: string; password?: string; basic?: string; header?: string }, urlSuffix = "", ): ServiceEndpoint => { - const chosen = securePM ?? plainPM; + // Plain-first (#95): on a stock CHR the TLS services are not dialable — + // `www-ssl` is disabled and `api-ssl` is certificate-less (TLS alert 40) — + // so preferring the secure forward advertised dead endpoints. Prefer the + // plain forward `restUrl` has always used; the secure forward remains the + // fallback so the `excludePorts` http-excluded case still resolves (with + // `tls: true`). Secure preference can return once boot provisioning + // installs a certificate and enables www-ssl. + const chosen = plainPM ?? securePM; if (!chosen) { return { available: false, unavailableReason: `no forwarded port for ${serviceLabel}` }; } @@ -306,11 +313,10 @@ function createInstance(state: MachineState): ChrInstance { return { available: true, ...echo, auth: authObj }; }; - // TLS preference is new logic (no pre-existing REST-URL preference to "keep" — - // see docs/centrs-interface.md's TLS section): prefer the secure port when its - // forward exists, fall back to plain, `available:false` only if neither does. - // This also closes a latent bug where excluding "http" while keeping "https" - // left restUrl pointing at a port that doesn't exist. + // Preference order lives in buildHttpService (plain-first, #95); this also + // keeps the excludePorts fix: excluding "http" while keeping "https" now + // resolves rest-api onto the surviving secure port instead of a port that + // doesn't exist. const restApi = buildHttpService( state.ports.https, state.ports.http, diff --git a/test/unit/cli-env-inspect.test.ts b/test/unit/cli-env-inspect.test.ts index 8ab7956..995fd54 100644 --- a/test/unit/cli-env-inspect.test.ts +++ b/test/unit/cli-env-inspect.test.ts @@ -95,8 +95,10 @@ describe("CLI env/inspect descriptors", () => { version: "7.22.1", arch: "x86", services: { - "rest-api": { available: true, tls: true, port: 19101 }, - "native-api": { available: true, tls: true, port: 19104 }, + // Plain-first (#95): stock-CHR TLS services are not dialable, so the + // descriptor advertises the plain forwards. + "rest-api": { available: true, tls: false, port: 19100 }, + "native-api": { available: true, tls: false, port: 19103 }, ssh: { available: true, tls: false, port: 19102 }, }, }); diff --git a/test/unit/descriptor.test.ts b/test/unit/descriptor.test.ts index bb72e03..962b775 100644 --- a/test/unit/descriptor.test.ts +++ b/test/unit/descriptor.test.ts @@ -85,24 +85,27 @@ describe("descriptor() — v1 shape", () => { expect(roundTripped.services["rest-api"].available).toBe(true); }); - test("rest-api and native-api prefer the secure port and report tls correctly", async () => { + test("rest-api and native-api prefer the PLAIN port (#95) and report tls correctly", async () => { + // On a stock CHR the TLS services are not dialable (www-ssl disabled, + // api-ssl certificate-less), so the descriptor must advertise the plain + // forwards restUrl has always used — never a dead secure endpoint. writeMachine(baseState("router")); const descriptor = await QuickCHR.get("router")?.descriptor(); const restApi = descriptor?.services["rest-api"]; const nativeApi = descriptor?.services["native-api"]; expect(restApi?.available).toBe(true); if (restApi?.available) { - expect(restApi.tls).toBe(true); - expect(restApi.port).toBe(19101); + expect(restApi.tls).toBe(false); + expect(restApi.port).toBe(19100); expect(restApi.transport).toBe("tcp"); - expect(restApi.url).toBe("https://127.0.0.1:19101/rest"); + expect(restApi.url).toBe("http://127.0.0.1:19100/rest"); expect(restApi.auth?.username).toBe("quickchr"); } expect(nativeApi?.available).toBe(true); if (nativeApi?.available) { - expect(nativeApi.tls).toBe(true); - expect(nativeApi.port).toBe(19104); - expect(nativeApi.url).toBe("tls://127.0.0.1:19104"); + expect(nativeApi.tls).toBe(false); + expect(nativeApi.port).toBe(19103); + expect(nativeApi.url).toBe("tcp://127.0.0.1:19103"); } }); From 60f9ebf454992831a642844e67d438bc9b16ecdc Mon Sep 17 00:00:00 2001 From: SkyFi Geek <45924209+mobileskyfi@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:44:56 -0700 Subject: [PATCH 2/3] ci: publish releases from committed package version --- .github/instructions/ci.instructions.md | 42 ++++++----- .github/workflows/release.yml | 92 ++++++++++++------------- CHANGELOG.md | 10 +++ CONTRIBUTING.md | 10 +-- DESIGN.md | 9 +-- examples/README.md | 2 +- package.json | 2 +- scripts/release-prep.ts | 72 +++++++++++++++++-- test/unit/release-prep.test.ts | 29 ++++++-- 9 files changed, 184 insertions(+), 84 deletions(-) diff --git a/.github/instructions/ci.instructions.md b/.github/instructions/ci.instructions.md index 5444adf..784bce8 100644 --- a/.github/instructions/ci.instructions.md +++ b/.github/instructions/ci.instructions.md @@ -26,7 +26,7 @@ The repo has several workflows, each with a distinct purpose: | **Weekly Sweep** | `sweep.yml` | schedule (Mon 05:37 UTC), `workflow_dispatch` | All-platform sweep + examples smoke (separate file so a red TCG leg never blocks PRs) | | **Integration** | `integration.yml` | `workflow_dispatch`, `workflow_call` | THE reusable integration unit — any platform × RouterOS target × test filter | | **PowerShell Lint** | `lint-powershell.yml` | push/PR touching `examples/**/*.ps1` or `PSScriptAnalyzerSettings.psd1`, `workflow_call` | PSScriptAnalyzer over the `.ps1` example mirrors | -| **Release** | `release.yml` | `workflow_dispatch` only | One-click gate → version bump → tag → GitHub Release → npm publish | +| **Release** | `release.yml` | `workflow_dispatch` only | One-click gate → tag/GitHub Release → npm publish from committed `package.json` | | **RouterOS Versions** | `ros-versions.yml` | schedule (daily 04:17 UTC), `workflow_dispatch` | New-version check → dispatches integration on never-tested versions | | **Lab** | `lab.yml` | `workflow_dispatch` (pick a lab or `all`), push touching `test/lab/**` | One job per `test/lab/*` grounding experiment that needs a real CI toolchain (e.g. host-OS OpenSSH/OpenSSL defaults). **Non-gating, must NOT be a required check** — findings go to the job summary + artifact and are written up in the lab's REPORT.md. Add a lab job per the framework header in the file | @@ -72,11 +72,14 @@ freshness gate — but a red sweep is still a real failure to investigate, never ### Release pipeline (release.yml) -One-click `workflow_dispatch` — no tag pushing, no local script, no suite re-run: +One-click `workflow_dispatch` after the version/changelog commit has already landed on +`main` — no tag pushing by hand, no suite re-run, and no CI push back to `main`: ```text -prepare (gate: main-only + Integration freshness green + [Unreleased] non-empty - → unit re-check → release-prep.ts bump/rollover → commit+tag+GitHub Release) +prepare (gate: main-only + Integration freshness green + + package.json version has a non-empty CHANGELOG section + + tag/GitHub Release/npm version unused + → unit re-check → tag+GitHub Release) → publish (npm publish --provenance, dist-tag from odd/even minor) ``` @@ -84,6 +87,11 @@ The integration bar is the freshness gate: the latest completed `main.yml` run o must be green — the same full x86+arm64 suite the old publish pipeline re-ran, but paid continuously on every push instead of at release time (arm64 gate lineage: #15/#16). +`package.json` is the source of truth. CI reads it, extracts release notes from the +matching `CHANGELOG.md` section via `scripts/release-prep.ts --from-package`, and never +updates tracked files. For a bugfix release, bump `package.json` and promote +`CHANGELOG.md` in the PR before dispatching `release.yml`. + ### Integration (integration.yml) The single owner of integration-test execution ([#29](https://github.com/tikoci/quickchr/issues/29)) — no other workflow defines its own CHR runner logic. Two faces: `workflow_call` for wrapper workflows, and `workflow_dispatch` as the manual/lab face. **One dispatch runs a full platforms × targets matrix**: `platforms` (comma list or `gating`/`all` alias) crossed with `routeros-targets` (comma list of channels and/or pinned versions) — e.g. `platforms=gating` + `routeros-targets=stable,long-term,7.24rc1` is nine legs in one run, no wrapper jobs or repeat dispatches. `run-integration`/`run-examples` choose what runs (examples default **ON** — they are part of the flow); the ref in the "Run workflow" dropdown chooses the branch. Use `test-filter`/`example-filter` to narrow. @@ -139,31 +147,31 @@ debugging "why is this platform slow" should read `tested-versions.json` and the ## Release Process ```bash -gh workflow run release.yml -f version-bump=patch # release now -gh workflow run release.yml -f version-bump=patch -f dry-run=true # preview first -gh workflow run release.yml -f version-bump=exact -f exact-version=0.6.0 +gh workflow run release.yml -f dry-run=true # preview package.json version +gh workflow run release.yml # release package.json version ``` -`release.yml` (`workflow_dispatch` only, main-only) does everything: +`release.yml` (`workflow_dispatch` only, main-only) publishes the committed version: 1. **Gates**: latest `main.yml` integration run green (freshness — no suite re-run) and - `CHANGELOG.md` `[Unreleased]` non-empty (it becomes the release notes; the - end-of-session checklist keeps it current). Plus a quick `bun test test/unit/`. -2. **Mutation** (`scripts/release-prep.ts`, unit-tested): bump `package.json`, roll - `[Unreleased]` over to `## [X.Y.Z] — date`. -3. **Publish**: commit `release: vX.Y.Z` + annotated tag + GitHub Release (notes = the - changelog section), then `npm publish --provenance` from the tag. + `CHANGELOG.md` has a non-empty `## [X.Y.Z]` section matching `package.json`. Plus a + quick `bun test test/unit/` and checks that the tag, GitHub Release, and npm version + are unused. +2. **Tag/release**: create `vX.Y.Z` and the GitHub Release from the current main commit + (notes = the changelog section). +3. **Publish**: `npm publish --provenance` from the tag. **Pre-release vs stable** (from version minor — pick the version accordingly): - `0.1.x`, `0.3.x` — odd minor → `npm tag: next` (pre-release, GitHub Release marked pre-release) - `0.2.x`, `0.4.x` — even minor → `npm tag: latest` (stable release) **Dry run** (`dry-run: true`): every gate and the version/notes computation run; nothing -is committed, tagged, or published. +is tagged or published. **Required secret**: `NPM_TOKEN` — npm automation token with publish access to `@tikoci/quickchr`. **Main is always release-able**: that is the point of the freshness gate + squash-only -PRs. If the gate blocks a release, fix `main` — do not bypass the gate. +PRs. If the gate blocks a release, fix `main` — do not bypass the gate. Release CI must +not bump versions or push to protected `main`. ## RouterOS Version Scheduler (ros-versions.yml) @@ -376,7 +384,7 @@ QUICKCHR_INTEGRATION=1 bun test test/integration/ QUICKCHR_TEST_TARGET=long-term QUICKCHR_INTEGRATION=1 bun test test/integration/ # Release (one-click, runs in CI — see "Release Process"): -gh workflow run release.yml -f version-bump=patch +gh workflow run release.yml ``` ## CHR Image Caching diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66818c5..4f70add 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,45 +2,35 @@ name: Release # The one-click release path (replaces publish.yml + scripts/release.ts): # -# gh workflow run release.yml -f version-bump=patch # release now -# gh workflow run release.yml -f version-bump=patch -f dry-run=true # preview +# gh workflow run release.yml # release package.json version +# gh workflow run release.yml -f dry-run=true # preview # # What it does (prepare job → publish job): -# 1. Refuses to run off main; refuses a dirty [Unreleased] gate: +# 1. Refuses to run off main; refuses a missing changelog promotion: # - Integration freshness: latest completed main.yml run on main must be # GREEN (scripts/ci-freshness.ts — same gate PRs see). No suite re-run: # main is kept continuously release-able instead. -# - CHANGELOG.md [Unreleased] must be non-empty (it becomes the notes). +# - package.json's committed version is the release version. +# - CHANGELOG.md must already have a non-empty `## [X.Y.Z]` section for +# that version (it becomes the notes). # 2. Quick sanity re-check on the exact release commit: bun test test/unit/. -# 3. scripts/release-prep.ts: bump package.json, roll [Unreleased] over to -# `## [X.Y.Z] — date`, emit release notes. -# 4. Commit "release: vX.Y.Z", tag vX.Y.Z, push, create the GitHub Release -# (notes = the changelog section). -# 5. Publish to npm with --provenance. Dist-tag from the odd/even-minor +# 3. Create tag vX.Y.Z and the GitHub Release (notes = changelog section). +# 4. Publish to npm with --provenance. Dist-tag from the odd/even-minor # scheme: odd minor (0.5.x) → `next`, even minor (0.4.x) → `latest`. # # Dry-run mode runs every check and prints the would-be version/notes but -# pushes nothing and publishes nothing. +# creates no tag/release and publishes nothing. # # Required secret: NPM_TOKEN — npm automation token for @tikoci/quickchr. -# Note: the version-bump push to main triggers ci.yml + main.yml runs on the -# release commit as usual — the freshness gate for the NEXT release reads those. +# Release CI never updates package.json or CHANGELOG.md. Those changes are made +# by a human/PR before dispatch, so the workflow does not push to protected main. on: workflow_dispatch: inputs: - version-bump: - type: choice - description: "Version bump — or pick 'exact' and fill exact-version" - options: [patch, minor, major, exact] - default: patch - exact-version: - type: string - description: "Exact X.Y.Z (only used when version-bump = exact)" - default: "" dry-run: type: boolean - description: "Dry run — run all checks, push nothing, publish nothing" + description: "Dry run — run all checks, create no release, publish nothing" default: false permissions: @@ -52,9 +42,9 @@ concurrency: cancel-in-progress: false jobs: - # ── prepare: gates, version bump, tag, GitHub Release ──────────────────────── + # ── prepare: gates, tag, GitHub Release ────────────────────────────────────── prepare: - name: Gate, bump, tag + name: Gate, tag runs-on: ubuntu-latest permissions: contents: write @@ -83,25 +73,13 @@ jobs: set -eo pipefail bun test test/unit/ 2>&1 | tee /tmp/release-unit.txt | tail -5 - - name: Bump version + roll CHANGELOG + - name: Resolve release metadata id: prep env: - BUMP: ${{ inputs.version-bump }} - EXACT: ${{ inputs.exact-version }} DRY_RUN: ${{ inputs.dry-run }} run: | set -eo pipefail - ARG="$BUMP" - if [ "$BUMP" = "exact" ]; then - if [ -z "$EXACT" ]; then - echo "::error::version-bump=exact requires exact-version" - exit 1 - fi - ARG="$EXACT" - fi - FLAGS="" - [ "$DRY_RUN" = "true" ] && FLAGS="--dry-run" - bun scripts/release-prep.ts "$ARG" $FLAGS --notes-out /tmp/release-notes.md \ + bun scripts/release-prep.ts --from-package --notes-out /tmp/release-notes.md \ | tee /tmp/release-prep.txt VERSION=$(grep -m1 '^version=' /tmp/release-prep.txt | cut -d= -f2) NPM_TAG=$(grep -m1 '^npm-tag=' /tmp/release-prep.txt | cut -d= -f2) @@ -120,7 +98,35 @@ jobs: cat /tmp/release-notes.md 2>/dev/null || sed -n '/--- release notes/,$p' /tmp/release-prep.txt } >> "$GITHUB_STEP_SUMMARY" - - name: Commit, tag, push, create GitHub Release + - name: Verify release targets are unused + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.prep.outputs.version }} + run: | + set -eo pipefail + git fetch --tags --quiet origin + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + echo "::error::Tag v${VERSION} already exists" + exit 1 + fi + if gh release view "v${VERSION}" >/dev/null 2>&1; then + echo "::error::GitHub Release v${VERSION} already exists" + exit 1 + fi + PKG_NAME=$(node -p "require('./package.json').name") + NPM_VIEW_ERR=$(mktemp) + trap 'rm -f "$NPM_VIEW_ERR"' EXIT + if npm view "${PKG_NAME}@${VERSION}" version --registry https://registry.npmjs.org/ > /dev/null 2>"$NPM_VIEW_ERR"; then + echo "::error::${PKG_NAME}@${VERSION} is already published" + exit 1 + fi + if ! grep -Eq '^(npm error|npm ERR!) code E404$' "$NPM_VIEW_ERR"; then + cat "$NPM_VIEW_ERR" >&2 + echo "::error::Could not verify npm availability for ${PKG_NAME}@${VERSION}" + exit 1 + fi + + - name: Create GitHub Release if: ${{ inputs.dry-run != true }} env: GH_TOKEN: ${{ github.token }} @@ -128,16 +134,10 @@ jobs: NPM_TAG: ${{ steps.prep.outputs.npm-tag }} run: | set -eo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package.json CHANGELOG.md - git commit -m "release: v${VERSION}" - git tag -a "v${VERSION}" -m "Release v${VERSION}" - git push origin main "v${VERSION}" PRERELEASE="" [ "$NPM_TAG" = "next" ] && PRERELEASE="--prerelease" gh release create "v${VERSION}" --title "v${VERSION}" \ - --notes-file /tmp/release-notes.md $PRERELEASE + --target "$GITHUB_SHA" --notes-file /tmp/release-notes.md $PRERELEASE # ── publish: npm with provenance ───────────────────────────────────────────── publish: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1796f..c2455a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Even minor versions (0.2.x, 0.4.x) are releases; odd minors (0.3.x, 0.5.x) are p ## [Unreleased] +## [0.4.5] — 2026-07-18 + +### Changed + +- Release CI now publishes from the committed `package.json` version instead of + bumping `package.json`/`CHANGELOG.md` and pushing a release commit to protected + `main` (issue #94). Maintainers promote the changelog and version before + dispatching `release.yml`; the workflow gates that state, creates the GitHub + Release/tag, and publishes npm. + ### Fixed - **Descriptor v1 now advertises the plain forwards (plain-first), not the TLS ones** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c2be6b..cc23908 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,9 +59,9 @@ Open a Pull Request against a filed issue for review (PRs are wired to automated - **Need integration signal for your branch before merging?** Dispatch the reusable unit: `gh workflow run integration.yml --ref -f platforms=linux-x86 -f test-filter=` (see `.github/instructions/ci.instructions.md` for platforms/targets/filters). -- **Keep `CHANGELOG.md` `[Unreleased]` current** for user-facing changes — it is the - direct input to releases: `release.yml` refuses to release an empty `[Unreleased]` - and turns it into the release notes. +- **Keep `CHANGELOG.md` `[Unreleased]` current** for user-facing changes. Before a + release, promote it to `## [X.Y.Z]` and bump `package.json`; `release.yml` publishes + that committed version and uses the matching changelog section as release notes. ## Releasing @@ -69,8 +69,8 @@ One-click, maintainer-triggered (see "Release Process" in `.github/instructions/ci.instructions.md`): ```bash -gh workflow run release.yml -f version-bump=patch -f dry-run=true # preview -gh workflow run release.yml -f version-bump=patch # release +gh workflow run release.yml -f dry-run=true # preview package.json version +gh workflow run release.yml # release package.json version ``` ## Development Setup diff --git a/DESIGN.md b/DESIGN.md index 88d8464..5f3eccf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -154,10 +154,11 @@ with a layered design: dispatches. `test-filter` narrows; `run-examples` defaults ON. Each runner boots its native CHR arch — `detectAccel()` picks KVM/HVF/TCG. Agents dispatch this to ground platform hypotheses without waiting for a PR cycle. -- **`release.yml`** — one-click release: freshness gate (no suite re-run — main is kept - continuously release-able) + non-empty CHANGELOG `[Unreleased]` → - `scripts/release-prep.ts` bump/rollover → tag + GitHub Release + `npm publish - --provenance` (odd minor → `next`, even → `latest`). +- **`release.yml`** — one-click publish from committed release state: freshness gate + (no suite re-run — main is kept continuously release-able) + `package.json` version + with a matching non-empty CHANGELOG section → tag + GitHub Release + `npm publish + --provenance` (odd minor → `next`, even → `latest`). CI never bumps versions or pushes + to protected `main`. - **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86 record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a single integration dispatch. diff --git a/examples/README.md b/examples/README.md index 6ce34ad..c5ee414 100644 --- a/examples/README.md +++ b/examples/README.md @@ -86,7 +86,7 @@ bun add @tikoci/quickchr # stable channel (latest) bun add @tikoci/quickchr@next # pre-release channel ``` -Channel policy (see `scripts/release-prep.ts`): **even** minor versions (`0.2.x`, +Channel policy (see `.github/workflows/release.yml`): **even** minor versions (`0.2.x`, `0.4.x`, …) publish to npm tag `latest` (stable); **odd** minors (`0.3.x`, …) publish to `next` (pre-release). diff --git a/package.json b/package.json index 1be5e3b..f9519e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tikoci/quickchr", - "version": "0.4.4", + "version": "0.4.5", "description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU", "module": "src/index.ts", "main": "src/index.ts", diff --git a/scripts/release-prep.ts b/scripts/release-prep.ts index 7145216..377e0ae 100644 --- a/scripts/release-prep.ts +++ b/scripts/release-prep.ts @@ -1,10 +1,12 @@ #!/usr/bin/env bun /** * release-prep — compute the next version, roll CHANGELOG.md's [Unreleased] - * section over to it, and bump package.json. The mutation half of release.yml - * (see ci.instructions.md "Release Process"); the workflow does the git/npm side. + * section over to it, and bump package.json for a local/manual release-prep PR. + * The CI release workflow uses --from-package instead: it reads committed + * package.json + CHANGELOG.md and never mutates tracked files. * * Usage: bun scripts/release-prep.ts [--dry-run] [--notes-out ] + * bun scripts/release-prep.ts --from-package [--notes-out ] * * - Fails if CHANGELOG.md's [Unreleased] section is empty — a release must say * what changed. (The end-of-session checklist keeps [Unreleased] current.) @@ -44,6 +46,10 @@ export function npmTag(version: string): "next" | "latest" { return Number(m[1]) % 2 !== 0 ? "next" : "latest"; } +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export interface Rollover { changelog: string; notes: string; @@ -77,9 +83,40 @@ export function rolloverChangelog(text: string, version: string, date: string): return { changelog: out.join("\n"), notes }; } +/** Extract the already-promoted changelog section for package.json's version. */ +export function releaseNotesForVersion(text: string, version: string): string { + const lines = text.split("\n"); + const heading = new RegExp(`^## \\[${escapeRegex(version)}\\](?:\\s|$)`); + const versionIdx = lines.findIndex((l) => heading.test(l)); + if (versionIdx < 0) { + throw new Error(`CHANGELOG.md has no '## [${version}]' heading`); + } + let nextIdx = lines.findIndex((l, i) => i > versionIdx && /^## /.test(l)); + if (nextIdx < 0) nextIdx = lines.length; + const notes = lines.slice(versionIdx + 1, nextIdx).join("\n").trim(); + if (!notes) { + throw new Error(`CHANGELOG.md section '## [${version}]' is empty`); + } + return notes; +} + +function positionals(args: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg === "--notes-out") { + i += 1; + continue; + } + if (!arg.startsWith("--")) out.push(arg); + } + return out; +} + async function main(): Promise { const args = process.argv.slice(2); - const bump = args.find((a) => !a.startsWith("--")); + const fromPackage = args.includes("--from-package"); + const [bump] = positionals(args); const dryRun = args.includes("--dry-run"); const notesOutIdx = args.indexOf("--notes-out"); const notesOut = notesOutIdx >= 0 ? args[notesOutIdx + 1] : undefined; @@ -87,15 +124,38 @@ async function main(): Promise { console.error("--notes-out requires a file path"); process.exit(2); } - if (!bump) { - console.error("usage: bun scripts/release-prep.ts [--dry-run] [--notes-out ]"); + if (fromPackage && bump) { + console.error("--from-package does not accept a bump argument"); + process.exit(2); + } + if (!fromPackage && !bump) { + console.error( + "usage: bun scripts/release-prep.ts [--dry-run] [--notes-out ]\n" + + " bun scripts/release-prep.ts --from-package [--notes-out ]", + ); process.exit(2); } const pkgPath = join(ROOT, "package.json"); const clPath = join(ROOT, "CHANGELOG.md"); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - const version = nextVersion(pkg.version, bump); + if (!/^\d+\.\d+\.\d+$/.test(pkg.version)) { + throw new Error(`package.json version "${pkg.version}" is not X.Y.Z`); + } + + if (fromPackage) { + const version = pkg.version; + const tag = npmTag(version); + const notes = releaseNotesForVersion(readFileSync(clPath, "utf-8"), version); + if (notesOut) writeFileSync(notesOut, `${notes}\n`); + console.log(`version=${version}`); + console.log(`npm-tag=${tag}`); + console.log("from-package=true"); + console.log(`\n--- release notes (${version}) ---\n${notes}`); + return; + } + + const version = nextVersion(pkg.version, bump ?? ""); const tag = npmTag(version); const date = new Date().toISOString().slice(0, 10); diff --git a/test/unit/release-prep.test.ts b/test/unit/release-prep.test.ts index 90773e1..0687661 100644 --- a/test/unit/release-prep.test.ts +++ b/test/unit/release-prep.test.ts @@ -1,9 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { nextVersion, npmTag, rolloverChangelog } from "../../scripts/release-prep.ts"; +import { + nextVersion, + npmTag, + releaseNotesForVersion, + rolloverChangelog, +} from "../../scripts/release-prep.ts"; -// Anchor tests for the release mutation half of release.yml -// (scripts/release-prep.ts): version arithmetic, the odd/even-minor npm -// dist-tag scheme, and the CHANGELOG [Unreleased] rollover. +// Anchor tests for release metadata: version arithmetic, the odd/even-minor npm +// dist-tag scheme, the local CHANGELOG rollover helper, and the CI read-only +// package.json-as-truth release notes path. describe("nextVersion", () => { test("patch/minor/major arithmetic", () => { @@ -69,3 +74,19 @@ describe("rolloverChangelog", () => { ); }); }); + +describe("releaseNotesForVersion", () => { + test("extracts an already-promoted release section", () => { + const notes = releaseNotesForVersion(CHANGELOG, "0.4.2"); + expect(notes).toBe("- old entry"); + }); + + test("missing version heading is an error", () => { + expect(() => releaseNotesForVersion(CHANGELOG, "0.4.3")).toThrow(/no '## \[0\.4\.3\]'/); + }); + + test("empty version section is an error", () => { + const empty = "# Changelog\n\n## [0.4.3]\n\n## [0.4.2]\n\n- old\n"; + expect(() => releaseNotesForVersion(empty, "0.4.3")).toThrow(/section '## \[0\.4\.3\]' is empty/); + }); +}); From 962e3a994e11e2d95acb74fababb16d5abec2ab4 Mon Sep 17 00:00:00 2001 From: SkyFi Geek <45924209+mobileskyfi@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:02:44 -0700 Subject: [PATCH 3/3] fix: address release review comments --- .github/workflows/release.yml | 3 +++ scripts/release-prep.ts | 17 ++++++++++------- src/lib/quickchr.ts | 6 +++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f70add..24400e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,9 @@ jobs: steps: - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # setup-bun action v2.2.0 + - uses: actions/setup-node@v5 + with: + node-version: 22 - run: bun install --frozen-lockfile - name: Refuse non-main refs diff --git a/scripts/release-prep.ts b/scripts/release-prep.ts index 377e0ae..34ae143 100644 --- a/scripts/release-prep.ts +++ b/scripts/release-prep.ts @@ -1,16 +1,19 @@ #!/usr/bin/env bun /** - * release-prep — compute the next version, roll CHANGELOG.md's [Unreleased] - * section over to it, and bump package.json for a local/manual release-prep PR. - * The CI release workflow uses --from-package instead: it reads committed - * package.json + CHANGELOG.md and never mutates tracked files. + * release-prep — release metadata helper with two modes: + * + * - Local prep mode computes the next version, rolls CHANGELOG.md's [Unreleased] + * section over to it, and bumps package.json for a manual/PR-owned release prep. + * - CI mode (--from-package) reads the committed package.json version plus its + * matching CHANGELOG.md section and never mutates tracked files. * * Usage: bun scripts/release-prep.ts [--dry-run] [--notes-out ] * bun scripts/release-prep.ts --from-package [--notes-out ] * - * - Fails if CHANGELOG.md's [Unreleased] section is empty — a release must say - * what changed. (The end-of-session checklist keeps [Unreleased] current.) - * - Inserts `## [X.Y.Z] — YYYY-MM-DD` below a fresh [Unreleased] heading. + * - Local prep mode fails if CHANGELOG.md's [Unreleased] section is empty, then + * inserts `## [X.Y.Z] — YYYY-MM-DD` below a fresh [Unreleased] heading. + * - CI mode fails if CHANGELOG.md lacks a non-empty `## [X.Y.Z]` section matching + * package.json. * - Prints machine-readable lines: `version=X.Y.Z` and `npm-tag=next|latest` * (odd minor → next, even minor → latest — the repo's pre-release scheme). * - --notes-out writes just the released section body (the GitHub Release notes). diff --git a/src/lib/quickchr.ts b/src/lib/quickchr.ts index 78604c6..1ea00fa 100644 --- a/src/lib/quickchr.ts +++ b/src/lib/quickchr.ts @@ -289,9 +289,9 @@ function createInstance(state: MachineState): ChrInstance { // Plain-first (#95): on a stock CHR the TLS services are not dialable — // `www-ssl` is disabled and `api-ssl` is certificate-less (TLS alert 40) — // so preferring the secure forward advertised dead endpoints. Prefer the - // plain forward `restUrl` has always used; the secure forward remains the - // fallback so the `excludePorts` http-excluded case still resolves (with - // `tls: true`). Secure preference can return once boot provisioning + // plain forward for each service; the secure forward remains the fallback + // so excluded plain ports still resolve when a secure forward is available + // (with `tls: true`). Secure preference can return once boot provisioning // installs a certificate and enables www-ssl. const chosen = plainPM ?? securePM; if (!chosen) {