diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a37e2e0..4dc371d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,19 @@ jobs: # so CI runs the VM suite only. - name: Test run: yarn mocha 'src/**/*.spec.ts' -r ts-node/register + + # npm pack runs prepack, so this step needs the install above. + - name: Packaging + run: bash scripts/packaging.test.sh + + release-notes: + runs-on: ubuntu-latest + name: release notes + steps: + - uses: actions/checkout@v4 + + # The script builds its own git repository under mktemp and sets the + # commit identity inside it, so the job needs no node setup and no git + # configuration of its own. + - name: Test + run: bash scripts/release-notes.test.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 376239d..90586c8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,7 @@ name: Publish # Tag-triggered publish to npm. A pushed v* tag starts the run, but the # publish step is gated on maintainer sign-off through the npm-publish -# environment and authenticates with that environment's NPM_TOKEN secret. +# environment and authenticates through npm trusted publishing (OIDC). on: push: tags: ['v*'] @@ -11,20 +11,21 @@ jobs: publish: runs-on: ubuntu-latest # Required-reviewer environment: a pushed tag queues the publish until a - # maintainer approves the run, and the environment scopes the NPM_TOKEN - # secret so no other workflow can read it. id-token stays enabled for - # npm provenance attestation. + # maintainer approves the run. The job holds no npm credential, so it + # writes no .npmrc auth entry and npm exchanges the id-token below for a + # short-lived one; that same token signs the provenance attestation. environment: npm-publish permissions: contents: read id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@v4 + # No registry-url: it writes an .npmrc _authToken line, and npm stops at + # that unresolvable credential instead of falling through to OIDC. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 - registry-url: https://registry.npmjs.org cache: yarn - name: Upgrade npm @@ -51,5 +52,3 @@ jobs: - name: Publish run: npm publish --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f17e203 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## [2.2.0] + +Renames the package to `@atomichub/vert` and gates chain-specific host functions to the emulated chain. + +### Breaking changes + +- The package is published as `@atomichub/vert`, not `@waxio/vert`. Change the install name and every import to the new name. `0bb4d95` + +### Features + +- Host functions are gated per emulated chain. `new Blockchain({ chain: 'wax' })` opts into the WAX set, which includes `verify_rsa_sha256_sig`, and the generic default withholds chain-specific functions. A contract that passes the harness therefore also passes `setcode` on chains that do not provide them. (#1) + +### Other changes + +- The npm publish is tag-triggered and gated on the `npm-publish` environment. `prepack` builds `dist`, so a publish always ships compiled output. (#2) diff --git a/README.md b/README.md index 2220d94..167ed74 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# VeRT +# @atomichub/vert (VeRT) + +[![npm version](https://img.shields.io/npm/v/%40atomichub%2Fvert)](https://www.npmjs.com/package/@atomichub/vert) [![CI](https://github.com/atomicassets/vert/actions/workflows/ci.yml/badge.svg)](https://github.com/atomicassets/vert/actions/workflows/ci.yml) [![License](https://img.shields.io/npm/l/%40atomichub%2Fvert)](LICENSE) **VM emulation RunTime for WASM-based blockchain contracts** @@ -13,6 +15,12 @@ The focus of VeRT is on the better compatibility than the performance, so it can - Minimum dependencies (No native wrapper, docker or remote connection) - Volatile key-value store with state rollback +## Installation + +```shell +npm install @atomichub/vert +``` + ## Requirement - WebAssembly binary with exported memory @@ -40,12 +48,6 @@ instantiates under a `wax` blockchain and fails to instantiate under any other, The chain-specific host functions are declared in `CHAIN_SPECIFIC_HOST_FUNCTIONS`; add an entry there to model a new one. -## Installation - -```shell -npm install @atomichub/vert -``` - ## Example usage ```typescript diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..6a61b06 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,198 @@ +# Releasing @atomichub/vert + +How a version of this package reaches npm and GitHub. A release ends at a +rendered GitHub Release, not at the npm publish. + +## Checklist + +1. The feature PR carries the `CHANGELOG.md` entry for the version under + `## [X.Y.Z]`, written in the section shape below with H3 headings, and + lands on `main`. The entry is the editorial text of the Release, so it is + written once, in the PR that makes the change. + +2. Land a `chore(release): X.Y.Z` commit on `main` that bumps the version in + `package.json` and touches nothing else. Read the `CHANGELOG.md` entry + against the template below now, because the next step publishes a tag. + +3. Tag the release commit and push the tag: + + ```sh + git tag vX.Y.Z && git push origin vX.Y.Z + ``` + + `.github/workflows/publish.yml` starts and waits on the `npm-publish` + environment. Push the tag before creating the Release, because + `gh release create` resolves the tag rather than creating it. The tag is + the release: consumers pin or float on it, so push it only once the entry + and the code behind it are ready. + +4. Compose the body, read it, then create the Release: + + ```sh + scripts/release-notes.sh vX.Y.Z > notes.md + gh release create vX.Y.Z --verify-tag --title vX.Y.Z --notes-file notes.md + ``` + + Add `--prerelease` for a candidate tag such as `vX.Y.Z-rc1`, so the + candidate does not take the latest marker. With more than one release in + flight, create them in ascending version order, so that marker stays + monotonic. + +5. Approve the `npm-publish` environment for the tag. With more than one + release waiting, approve in ascending version order, so the npm `latest` + tag stays monotonic. + +6. Verify the published version and the rendered Release: + + ```sh + npm view @atomichub/vert version + gh release view vX.Y.Z + ``` + +## Publish auth + +The publish job authenticates through npm trusted publishing (OIDC). It holds +no npm token and sets no registry URL on the setup step, so nothing writes an +`.npmrc` auth entry and npm 11.5.1 or later exchanges the job's OIDC identity +for a short-lived credential of its own. The `npm-publish` environment is the +gate on that identity: a pushed tag queues the run until a maintainer approves +it. + +`publishConfig.provenance` in `package.json` makes a default local npm publish +fail, because no OIDC identity is available outside CI to satisfy it. It is +data inside the manifest being published, not an access control. The durable +control is the npm-side package setting that requires trusted publishing, +which is configured for `@atomichub/vert`. + +## Body template + +The Release title is the tag name verbatim. The body is an optional +one-sentence summary, then the sections that have items, then the commit list, +then the compare link as the last line. Nothing follows the link, and a +section with no items is left out. + +``` + + +## Breaking changes + +- . (#N) + +## Upgrading + +- . (#N) + +## Features + +- . (#N) + +## Bug fixes + +- . (#N) + +## Security + +- . (#N) + +## Deprecations + +- . (#N) + +## Other changes + +- . (#N) + +## Commits + +- + +Full changelog: https://github.com/atomicassets/vert/compare/... +``` + +The section order is breaking changes, upgrading, features, bug fixes, +security, deprecations, other changes. `## Security` carries advisories and +dependency lifts, each naming its GHSA or CVE identifier; a release with none +leaves the section out. + +## Voice + +- Neutral and factual, the register of the Node.js or esbuild release notes. +- Sectioned. The heading says what kind of change it is, so the item does not + repeat it. +- One to three plain sentences per item: what changed, and what the reader + does about it when action is needed. Code identifiers in backticks. +- Every item ends with its PR reference `(#N)`, or with its short sha in + backticks when the change had no PR. +- No preface, no motivation essay, no clause chain explaining how the author + got there. The why stays only where it changes what the reader does. +- Present tense for the new behavior, sentence-case headings, straight quotes, + and no em-dash. + +## The CHANGELOG entry + +`CHANGELOG.md` is where the editorial text is written, and the Release body is +that entry with its headings promoted one level. An entry heading is +`## [X.Y.Z]`. Under it comes an optional one-line summary, then the H3 +sections in the order above. A candidate tag reads the entry for its base +version as it stands at that tag, so `vX.Y.Z-rc1` reads `## [X.Y.Z]`. + +## Tag ranges and older releases + +- `PREV` for a stable tag is the nearest earlier stable `v*` tag, so a stable + release lists every commit since the last stable release and skips the + prereleases between them. `PREV` for a prerelease tag is the nearest earlier + tag of any kind. A stable tag whose only earlier tags are prereleases takes + the nearest of them. +- `## Commits` lists the whole `PREV..TAG` range, oldest first, including the + release commit. Its line count equals `git rev-list --count PREV..TAG`. +- `v2.1.1` is the upstream-base tag. It marks the last commit taken from + upstream, where `package.json` still read `@waxio/vert`, and it carries no + Release by design. `v2.2.0` bounds its commit range on it. No tag earlier + than `v2.1.1` exists in this repository. +- A tag with no earlier tag has no `PREV`. Its body is the summary and the + sentence `Initial release.`, with no commit list and no compare link, and it + is written by hand. +- A Release created for a tag older than the current latest is created with + `--latest=false`, so the latest marker stays on the newest version. + +`scripts/release-notes.sh` needs bash, git, awk and sed. It reads +`CHANGELOG.md` at the tag rather than from the working tree, so the body +describes what the tag ships. A second argument names a ref to read instead: +`scripts/release-notes.sh vX.Y.Z main` composes the same body from `main` +before the tag exists, prints the range it used on stderr, and refuses the +preview once that ref already carries the tag. + +The script exits non-zero and names what is missing when no tag is given, when +the tag is neither v-prefixed nor bare semver, when the tag does not exist and +no ref was passed, when the CHANGELOG at that point carries no entry for the +version, and when no earlier tag in the namespace is reachable. +`scripts/release-notes.test.sh` is its paired check, and CI runs it on every +push and pull request. + +## Published package metadata + +A release publishes a package page as well as a Release, and the page reads +`package.json`. It carries `name` and `version`; `description` (one sentence +on what the package does and for whom); `license`, with the `LICENSE` file +shipped; `homepage`; `repository` (an object with `type: git` and the +`git+https` URL); `bugs` (an object with the issues URL); `author` (an object +with `name` and `url`); `keywords`; `engines`; `main`, `types` and the +`exports` map; `files` (the build output and the notices that must ship); +`sideEffects`; and `publishConfig` with `access: public` and +`provenance: true`. The package ships a CommonJS build only, so there is no +`module` field and the `exports` map declares the one entry and its type +declarations. + +The README is the npm page: it opens with the package name, badges for the npm +version, CI and license, a short introduction, and an install line. Upstream +and lineage credit lives there too, in the opening paragraph, so no Release +body carries a credits section. + +`npm pack --dry-run` lists what the tarball ships: `dist` with its type +declarations and source maps, `src` excluding its `tests` directories, +`README.md`, `LICENSE` and `package.json`. `src` ships so the source maps +resolve, but its test sources do not: `files` in `package.json` excludes +`src/**/tests`. A spec file anywhere in the list, compiled or source, means an +exclude stopped matching, and anything else unexpected is a `files` mistake. +`scripts/packaging.test.sh` runs that check in CI, on every Node version in +the matrix, so the tarball is proven before the tag. diff --git a/package.json b/package.json index c1613c3..09dee2a 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,31 @@ "version": "2.2.0", "homepage": "https://github.com/atomicassets/vert", "description": "Testing library for Antelope smart contracts, with per-chain host function parity", + "keywords": [ + "antelope", + "eosio", + "wax", + "smart-contracts", + "testing", + "wasm" + ], "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, "engines": { "node": ">=20" }, "files": [ "dist", - "src" + "src", + "!src/**/tests" ], "scripts": { "build": "rm -rf dist && tsc", @@ -20,8 +37,15 @@ "prepack": "npm run build", "prepublishOnly": "npm run test" }, - "author": "Jeeyong Um ", + "author": { + "name": "AtomicHub", + "url": "https://atomichub.io" + }, "license": "MIT", + "publishConfig": { + "access": "public", + "provenance": true + }, "devDependencies": { "@types/chai": "^4.3.11", "@types/elliptic": "^6.4.18", diff --git a/scripts/packaging.test.sh b/scripts/packaging.test.sh new file mode 100755 index 0000000..97771e4 --- /dev/null +++ b/scripts/packaging.test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Check what the published tarball ships. Runs npm pack --dry-run at the +# repository root and asserts the file list: the compiled entry, its type +# declarations, the README, the license, the manifest, no spec file anywhere, +# and no path outside the expected dist, src, README.md, LICENSE, and +# package.json set. +# +# Usage: bash scripts/packaging.test.sh +# +# prepack builds dist, so run this where the dependencies are installed. The +# script installs nothing itself. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CASES=0 +PASSED=0 + +ok() { + CASES=$((CASES + 1)) + PASSED=$((PASSED + 1)) + printf 'ok %s %s\n' "$CASES" "$1" +} + +no() { + CASES=$((CASES + 1)) + printf 'not ok %s %s\n %s\n' "$CASES" "$1" "$2" +} + +# npm prints the file list as JSON on stdout and its notices on stderr, so node +# reads the paths out of the JSON rather than the human-readable listing. +LIST="$(npm pack --dry-run --json | node -e ' +let raw = ""; +process.stdin.on("data", (chunk) => (raw += chunk)); +process.stdin.on("end", () => { + for (const file of JSON.parse(raw)[0].files) console.log(file.path); +}); +')" + +if [ -n "$LIST" ]; then + ok "npm pack --dry-run lists $(printf '%s\n' "$LIST" | wc -l | tr -d ' ') files" +else + no "npm pack --dry-run lists files" "the file list is empty" +fi + +for want in dist/index.js dist/index.d.ts README.md LICENSE package.json; do + if printf '%s\n' "$LIST" | grep -qxF "$want"; then + ok "the tarball ships $want" + else + no "the tarball ships $want" "$want is missing from the file list" + fi +done + +# tsconfig.json excludes the specs from the build and files excludes +# src/**/tests, so no spec file, compiled or source, should reach the tarball. +SPECS="$(printf '%s\n' "$LIST" | grep '\.spec\.' || true)" +if [ -z "$SPECS" ]; then + ok "the tarball ships no spec file" +else + no "the tarball ships no spec file" "$(printf '%s' "$SPECS" | tr '\n' ' ')" +fi + +# Everything else in the list should fall under dist or src, or be one of the +# three top-level files; anything left over is a files mistake. +UNEXPECTED="$(printf '%s\n' "$LIST" | grep -vE '^(dist/|src/)' | grep -vxF -e README.md -e LICENSE -e package.json || true)" +if [ -z "$UNEXPECTED" ]; then + ok "the tarball ships nothing outside dist, src, README.md, LICENSE, and package.json" +else + no "the tarball ships nothing outside dist, src, README.md, LICENSE, and package.json" "$(printf '%s' "$UNEXPECTED" | tr '\n' ' ')" +fi + +printf 'passed %s/%s\n' "$PASSED" "$CASES" +[ "$PASSED" -eq "$CASES" ] diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..90aaaa9 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Compose the GitHub Release body for a release tag: the CHANGELOG entry for the +# version, the commit list for the tag range, and the compare link. +# +# Usage: scripts/release-notes.sh [] (the body goes to stdout) +# +# With the tag need not exist yet: the entry and the commits are read at +# that ref, so a body can be reviewed before anything is tagged or built. +set -euo pipefail + +die() { + printf 'release-notes: %s\n' "$*" >&2 + exit 1 +} + +TAG="${1-}" +REF="${2-}" +[ -n "$TAG" ] || die "no tag given; usage: scripts/release-notes.sh []" + +# The tag shape picks the namespace. A repository may tag bare (2.1.0) or with +# a v prefix (v1.7.27), and a repository that runs both lines resolves each +# tag's previous tag among tags of its own shape, never crossing into the other. +case "$TAG" in + v[0-9]*) MATCH='v*' EXCLUDE='v*-*' ;; + [0-9]*) MATCH='[0-9]*' EXCLUDE='[0-9]*-*' ;; + *) die "tag $TAG is neither a bare semver tag nor a v-prefixed one" ;; +esac + +VERSION="${TAG#v}" +BASE="${VERSION%%-*}" + +# A stable tag lists everything since the last stable tag, so the prereleases +# between the two are excluded from the lookup. A prerelease takes the nearest +# tag of any kind, which is the previous prerelease when there is one. +DESCRIBE=(--tags --abbrev=0 --match "$MATCH") +case "$VERSION" in + *-*) ;; + *) DESCRIBE+=(--exclude "$EXCLUDE") ;; +esac + +if [ -n "$REF" ]; then + git rev-parse -q --verify "$REF^{commit}" >/dev/null || + die "ref $REF does not resolve to a commit in this repository" + SOURCE="$REF" + FROM="$REF" +else + git rev-parse -q --verify "refs/tags/$TAG" >/dev/null || + die "tag $TAG does not exist in this repository; pass a ref to preview it before tagging" + SOURCE="$TAG" + FROM="$TAG^" +fi + +ORIGIN="$(git remote get-url origin 2>/dev/null)" || + die "this repository has no origin remote" +# Only the GitHub URL forms git emits or accepts; anything else is refused +# rather than parsed into a compare link that points at the wrong host. +case "$ORIGIN" in + git@github.com:*) SLUG="${ORIGIN#git@github.com:}" ;; + ssh://git@github.com/*) SLUG="${ORIGIN#ssh://git@github.com/}" ;; + https://github.com/*) SLUG="${ORIGIN#https://github.com/}" ;; + https://*@github.com/*) SLUG="${ORIGIN#https://*@github.com/}" ;; + *) die "the origin remote is not a GitHub URL: $ORIGIN" ;; +esac +SLUG="${SLUG%/}" +SLUG="${SLUG%.git}" + +# git describe exits non-zero when no tag matches. That is the first-release +# case rather than a failure, so the status is read here instead of aborting. +# A stable tag whose only earlier tags are prereleases falls back to the +# nearest tag of any kind, so the first stable release after a candidate line +# still lists what it adds to the last candidate. +PREV="$(git describe "${DESCRIBE[@]}" "$FROM" 2>/dev/null || true)" +[ -n "$PREV" ] || + PREV="$(git describe --tags --abbrev=0 --match "$MATCH" "$FROM" 2>/dev/null || true)" +[ -n "$PREV" ] || + die "no earlier $MATCH tag reachable from $FROM; the first-release body is written by hand" +# In preview mode the ref may already carry the tag being composed, in which +# case PREV would be the tag itself and the commit list empty. +[ "$PREV" != "$TAG" ] || + die "$TAG already tags $REF; drop the ref argument to compose the body for the tag" + +# The CHANGELOG is read at the tag, not from the working tree, so the body +# describes what the tag ships, and a prerelease reads the entry for its base +# version as it stands there. The heading match is a prefix, so trailing text +# such as a date is ignored. sed drops the blank lines above the first line of +# content, and the command substitution ($(...)) drops the trailing ones. +ENTRY="$(git show "$SOURCE:CHANGELOG.md" | awk -v h="## [$BASE]" ' + !inside && ($0 == h || index($0, h " ") == 1) { inside = 1; next } + inside && /^## / { exit } + inside { print } +' | sed '/./,$!d')" +[ -n "$ENTRY" ] || die "CHANGELOG.md at $SOURCE has no \"## [$BASE]\" entry" + +[ -z "$REF" ] || + printf 'release-notes: preview from %s, commits %s..%s\n' "$REF" "$PREV" "$REF" >&2 + +printf '%s\n' "$ENTRY" | sed 's/^### /## /' +printf '\n## Commits\n\n' +git --no-pager log --no-decorate --no-show-signature --reverse --oneline "$PREV..$SOURCE" | sed 's/^/- /' +printf '\nFull changelog: https://github.com/%s/compare/%s...%s\n' "$SLUG" "$PREV" "$TAG" diff --git a/scripts/release-notes.test.sh b/scripts/release-notes.test.sh new file mode 100755 index 0000000..ce1adef --- /dev/null +++ b/scripts/release-notes.test.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Paired test for scripts/release-notes.sh. Builds a throwaway git repository +# under mktemp -d, runs the script inside it, and asserts the propositions the +# release checklist rests on: the two tag namespaces, the previous-tag rule for +# a stable tag and for a prerelease, the pre-tag preview, and the failures. +# +# Usage: bash scripts/release-notes.test.sh +set -euo pipefail + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-notes.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +CASES=0 +PASSED=0 + +ok() { + CASES=$((CASES + 1)) + PASSED=$((PASSED + 1)) + printf 'ok %s %s\n' "$CASES" "$1" +} + +no() { + CASES=$((CASES + 1)) + printf 'not ok %s %s\n %s\n' "$CASES" "$1" "$2" +} + +# The script under test runs as a separate process through its own shebang and +# executable bit, the way the checklist invokes it, so its errexit is not +# suppressed by this capture; RUN_RC carries the status explicitly. +RUN_OUT="" +RUN_RC=0 +run() { + RUN_RC=0 + RUN_OUT="$(scripts/release-notes.sh "$@" 2>&1)" || RUN_RC=$? +} + +commit() { + printf '%s\n' "$1" >>history.txt + git add -A + git commit -q -m "$1" +} + +commit_lines() { + printf '%s\n' "$RUN_OUT" | + awk '/^## Commits$/ { inside = 1; next } inside && /^- / { n++ } END { print n + 0 }' +} + +last_line() { + printf '%s\n' "$RUN_OUT" | tail -n 1 +} + +# --- fixture ----------------------------------------------------------------- + +mkdir -p "$WORK/repo/scripts" +cp "$SCRIPT" "$WORK/repo/scripts/release-notes.sh" +chmod +x "$WORK/repo/scripts/release-notes.sh" +cd "$WORK/repo" + +git -c init.defaultBranch=main init -q . +git config user.name "release-notes test" +git config user.email "release-notes-test@example.com" +git config commit.gpgsign false +git remote add origin https://github.com/example/repo.git + +cat >CHANGELOG.md <<'EOF' +# Changelog + +## [1.0.0] - 2026-01-01 + +The first stable release. +EOF +commit "feat: the first cut" +git tag v1.7.0 + +commit "fix: correct the coder" +git tag 1.0.0 + +cat >CHANGELOG.md <<'EOF' +# Changelog + +## [1.1.0] - unreleased + +The builder consumers asked for. + +### Features + +- A builder consumers can call without hand-rolling the payload. (#7) + +## [1.0.0] - 2026-01-01 + +The first stable release. +EOF +commit "feat: add the builder (#7)" +git tag 1.1.0-rc1 + +# A temp-file rewrite rather than sed -i, whose in-place flag differs between +# GNU and BSD sed. +sed 's/^## \[1\.1\.0\] - unreleased$/## [1.1.0] - 2026-02-01/' CHANGELOG.md >CHANGELOG.md.new +mv CHANGELOG.md.new CHANGELOG.md +commit "chore(release): 1.1.0" +git tag 1.1.0 + +commit "chore: a version the changelog does not document" +git tag 1.1.1 + +cat >CHANGELOG.md <<'EOF' +# Changelog + +## [1.2.0] + +The entry the preview reads before the tag exists. + +### Bug fixes + +- The coder keeps the trailing byte. (#9) + +## [1.1.0] - 2026-02-01 + +The builder consumers asked for. + +### Features + +- A builder consumers can call without hand-rolling the payload. (#7) + +## [1.0.0] - 2026-01-01 + +The first stable release. +EOF +commit "docs: open the 1.2.0 entry" + +git checkout -q -b release/1.7 v1.7.0 +cat >CHANGELOG.md <<'EOF' +# Changelog + +## [1.7.1] - 2026-01-05 + +The maintenance line takes the same fix. + +### Bug fixes + +- The coder keeps the trailing byte on the maintenance line. (#8) + +## [1.0.0] - 2026-01-01 + +The first stable release. +EOF +commit "fix: keep the trailing byte (#8)" +git tag v1.7.1 +git checkout -q main + +# --- 1: a stable tag skips the prerelease between it and the last stable one -- + +run 1.1.0 +want="$(git rev-list --count 1.0.0..1.1.0)" +got="$(commit_lines)" +if [ "$RUN_RC" -eq 0 ] && + [ "$(printf '%s\n' "$RUN_OUT" | head -n 1)" = "The builder consumers asked for." ] && + printf '%s\n' "$RUN_OUT" | grep -qx '## Features' && + ! printf '%s\n' "$RUN_OUT" | grep -qx '### Features' && + ! printf '%s\n' "$RUN_OUT" | grep -q '^## \[1\.1\.0\]' && + [ "$got" = "$want" ] && + [ "$(last_line)" = "Full changelog: https://github.com/example/repo/compare/1.0.0...1.1.0" ]; then + ok "1.1.0 drops the entry heading, promotes the sections, lists $want commits since 1.0.0, and ends with the link" +else + no "1.1.0 body" "rc=$RUN_RC commits=$got want=$want last='$(last_line)'" +fi + +# --- 2: a prerelease reads the base-version entry and takes the nearest tag --- + +run 1.1.0-rc1 +want="$(git rev-list --count 1.0.0..1.1.0-rc1)" +got="$(commit_lines)" +if [ "$RUN_RC" -eq 0 ] && + printf '%s\n' "$RUN_OUT" | grep -qx '## Features' && + [ "$got" = "$want" ] && + [ "$(last_line)" = "Full changelog: https://github.com/example/repo/compare/1.0.0...1.1.0-rc1" ]; then + ok "1.1.0-rc1 reads the [1.1.0] entry as it stands at the rc and lists the $want commit since 1.0.0" +else + no "1.1.0-rc1 body" "rc=$RUN_RC commits=$got want=$want last='$(last_line)'" +fi + +# --- 3: the v namespace resolves among v tags -------------------------------- + +run v1.7.1 +want="$(git rev-list --count v1.7.0..v1.7.1)" +got="$(commit_lines)" +if [ "$RUN_RC" -eq 0 ] && + printf '%s\n' "$RUN_OUT" | grep -qx '## Bug fixes' && + [ "$got" = "$want" ] && + [ "$(last_line)" = "Full changelog: https://github.com/example/repo/compare/v1.7.0...v1.7.1" ]; then + ok "v1.7.1 resolves the v namespace and links v1.7.0...v1.7.1" +else + no "v1.7.1 body" "rc=$RUN_RC commits=$got want=$want last='$(last_line)'" +fi + +# --- 4: the failures --------------------------------------------------------- + +run 9.9.9 +if [ "$RUN_RC" -ne 0 ] && printf '%s\n' "$RUN_OUT" | grep -q '9\.9\.9'; then + ok "an unknown tag exits non-zero and names the tag" +else + no "unknown tag" "rc=$RUN_RC out='$RUN_OUT'" +fi + +run 1.1.1 +if [ "$RUN_RC" -ne 0 ] && + printf '%s\n' "$RUN_OUT" | grep -q 'CHANGELOG' && + printf '%s\n' "$RUN_OUT" | grep -q '1\.1\.1'; then + ok "a version with no CHANGELOG entry at its tag exits non-zero and names the version" +else + no "missing CHANGELOG entry" "rc=$RUN_RC out='$RUN_OUT'" +fi + +run 1.0.0 +if [ "$RUN_RC" -ne 0 ] && printf '%s\n' "$RUN_OUT" | grep -q 'by hand'; then + ok "the first tag in its namespace exits non-zero and sends the body to be written by hand" +else + no "first tag in namespace" "rc=$RUN_RC out='$RUN_OUT'" +fi + +run +if [ "$RUN_RC" -ne 0 ] && printf '%s\n' "$RUN_OUT" | grep -q 'no tag given'; then + ok "no argument exits non-zero and names the missing tag" +else + no "no argument" "rc=$RUN_RC out='$RUN_OUT'" +fi + +# --- 5: a bare tag ignores a reachable v tag --------------------------------- + +run 1.1.0 +if [ "$RUN_RC" -eq 0 ] && + [ "$(last_line)" = "Full changelog: https://github.com/example/repo/compare/1.0.0...1.1.0" ] && + ! printf '%s\n' "$RUN_OUT" | grep -q 'v1\.7\.0'; then + ok "1.1.0 ignores the reachable v1.7.0 and resolves PREV in the bare namespace" +else + no "bare namespace isolation" "rc=$RUN_RC last='$(last_line)'" +fi + +# --- 6: the pre-tag preview -------------------------------------------------- + +run 1.2.0 main +want="$(git rev-list --count 1.1.1..main)" +got="$(commit_lines)" +if [ "$RUN_RC" -eq 0 ] && + printf '%s\n' "$RUN_OUT" | grep -qx '## Bug fixes' && + [ "$got" = "$want" ] && + [ "$(last_line)" = "Full changelog: https://github.com/example/repo/compare/1.1.1...1.2.0" ]; then + ok "1.2.0 main previews the body from the CHANGELOG at main and the $want commit since 1.1.1" +else + no "preview from main" "rc=$RUN_RC commits=$got want=$want last='$(last_line)'" +fi + +run 1.2.0 +if [ "$RUN_RC" -ne 0 ] && printf '%s\n' "$RUN_OUT" | grep -q '1\.2\.0'; then + ok "1.2.0 without a ref exits non-zero and names the absent tag" +else + no "absent tag without a ref" "rc=$RUN_RC out='$RUN_OUT'" +fi + +# --- 7: a preview from a ref that already carries the tag is refused -------- + +git tag 1.2.0 +run 1.2.0 main +if [ "$RUN_RC" -ne 0 ] && printf '%s\n' "$RUN_OUT" | grep -q 'already tags'; then + ok "1.2.0 main is refused once main carries the 1.2.0 tag" +else + no "preview from a tagged ref" "rc=$RUN_RC out='$RUN_OUT'" +fi + +# --- 8: a stable tag whose only earlier tags are prereleases takes the nearest - + +git checkout -q --orphan candidates +{ + printf '%s\n' '# Changelog' '' '## [3.0.0]' '' 'A line that started with candidates.' '' '### Features' '' + printf '%s\n' '- The first stable release of the line. (#20)' +} >CHANGELOG.md +commit "feat: start the 3.0 line" +git tag 3.0.0-rc1 +commit "fix: the candidate fix (#20)" +git tag 3.0.0 +run 3.0.0 +if [ "$RUN_RC" -eq 0 ] && + [ "$(last_line)" = "Full changelog: https://github.com/example/repo/compare/3.0.0-rc1...3.0.0" ] && + [ "$(commit_lines)" = "1" ]; then + ok "3.0.0 with only 3.0.0-rc1 before it falls back to the candidate and lists 1 commit" +else + no "stable after only prereleases" "rc=$RUN_RC last='$(last_line)' commits=$(commit_lines)" +fi +git checkout -q main + +printf 'passed %s/%s\n' "$PASSED" "$CASES" +[ "$PASSED" -eq "$CASES" ]