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
42 changes: 25 additions & 17 deletions .github/instructions/ci.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 → tagGitHub 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 |

Expand Down Expand Up @@ -72,18 +72,26 @@ 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)
```

The integration bar is the freshness gate: the latest completed `main.yml` run on `main`
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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
95 changes: 49 additions & 46 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -64,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
Expand All @@ -83,25 +76,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)
Expand All @@ -120,24 +101,46 @@ 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
Comment thread
mobileskyfi marked this conversation as resolved.
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 }}
VERSION: ${{ steps.prep.outputs.version }}
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:
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ 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**
(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
Expand Down
10 changes: 5 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,18 @@ 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 <branch> -f platforms=linux-x86 -f test-filter=<file>`
(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

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
Expand Down
9 changes: 5 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 11 additions & 8 deletions docs/centrs-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading