From 20fb992108500fd4060b743e209d3d826a818afe Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 18 Aug 2026 19:08:51 -0400 Subject: [PATCH] chore: adopt the release checklist and authenticate publishes with OIDC The v2.2.0 publish run failed for want of an npm token, and the tag shipped with no GitHub Release. Trusted publishing removes the token the run was missing: the job holds only an OIDC id-token, and setup-node writes no auth entry for npm to trip over. RELEASING.md and scripts/release-notes.sh make the Release body part of every release, composed from the CHANGELOG entry, and the packaging check proves the tarball before a tag, test sources now excluded. --- .github/workflows/ci.yml | 16 ++ .github/workflows/publish.yml | 17 +- CHANGELOG.md | 17 ++ README.md | 16 +- RELEASING.md | 198 +++++++++++++++++++++++ package.json | 28 +++- scripts/packaging.test.sh | 74 +++++++++ scripts/release-notes.sh | 100 ++++++++++++ scripts/release-notes.test.sh | 293 ++++++++++++++++++++++++++++++++++ 9 files changed, 741 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 RELEASING.md create mode 100755 scripts/packaging.test.sh create mode 100755 scripts/release-notes.sh create mode 100755 scripts/release-notes.test.sh 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" ]