diff --git a/.github/workflows/contracts-ci.yaml b/.github/workflows/contracts-ci.yaml index 033b659..5e61ed5 100644 --- a/.github/workflows/contracts-ci.yaml +++ b/.github/workflows/contracts-ci.yaml @@ -37,6 +37,13 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # scripts/release-notes.sh needs only bash, git, awk and sed, and its + # paired test builds its own throwaway repository, so it sits outside the + # harness guard below and runs before the CDT install. Every ref that + # carries this step also carries the script, so the step is unconditional. + - name: Run release-notes composer test + run: bash scripts/release-notes.test.sh + - name: Check for test harness id: harness run: | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d357b50 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +Notable changes to the AtomicAssets contract. This file starts at 2.0.0; the +releases published before it live in +[GitHub Releases](https://github.com/atomicassets/atomicassets-contract/releases). + +Entry headings keep the `## [X.Y.Z] - YYYY-MM-DD` form. Each entry opens with a +summary line, then carries the sections that `RELEASING.md` defines, in that +order; the entry is the editorial text of the version's GitHub Release. This +project follows semantic versioning. + +## [2.0.0] - 2026-08-03 + +The AtomicAssets v2 standard contract: mutable templates, per-field schema media types, collection author succession, template maintenance actions and a contract-wide CPU reduction. + +### Breaking changes + +- `backasset` fails unconditionally with `Native backing has been deprecated on the AtomicAssets Contract`, and `mintasset` rejects a non-empty `tokens_to_back`. Both actions stay in the ABI, so an ABI diff does not show the change and an integration that backs assets starts failing at the upgrade. Value already backed is not stranded: burning an asset still releases its `backed_tokens` to the owner's balance, and `withdraw`, `announcedepo` and the deposit path are unchanged. `0907db9` +- `createtempl` and `createtempl2` reject a template whose assets would be neither transferable nor burnable, with `A template cannot be both non-transferable and non-burnable`. Such an asset could never move and could never be destroyed, so it would hold its owner's RAM permanently. Every other flag combination stays valid, and the check runs at creation, so templates already in that state keep working. `0907db9` +- `addcolauth` and `addnotifyacc` refuse to extend a list that already holds 24 accounts, where v1 enforced no limit. These lists are walked on every notifying action. A collection already above the cap keeps every entry it has and can still remove them. `89774e9` +- `createcol` rejects more than 24 accounts in either list, the cap the incremental actions enforce. Without it, one transaction seeds a collection past the read budget. (#23) +- `atomicassets-interface.hpp` reaches its tables through accessor methods, `get_collections()`, `get_offers()`, `get_assets(owner)` and the rest, rather than the member instances v1 declared. A consumer contract that vendors the header changes its call sites. `0036796` + +### Upgrading + +| Asset | sha256 | +| --- | --- | +| `atomicassets.wasm` | `962a93e1adde9779d3afb84983cee076c3d2b50b0473c99fb0fe02573a7b7242` | +| `atomicassets.abi` | `b6389fdde10a16f2d94b12bb10a67b676f6449b1293b57cc0b18256652cfef68` | + +- The wasm sha256 equals the on-chain code hash, so `get_code_hash` confirms which bytes are running. The attached `SHA256SUMS` carries the same two values. +- The ABI is additive against v1: nothing is removed and no existing struct changes shape. The published ABI is the legacy-compat build, where `vector` fields render as `uint8[]` and the attribute-map pair fields keep their v1 `key` and `value` spellings (CDT 4.1 emits `first` and `second`, and the release build patches them back), so a v1 client or indexer keeps working unchanged. The ABI version moves from `eosio::abi/1.1` to `1.2`, and the behavior changes above are not expressed in the ABI at all. +- The final surface is 47 actions and 11 tables. Existing tables (`assets`, `templates`, `schemas`, `collections`, `config`, `offers`, `balances`, `tokenconfigs`) keep their layout, and the added tables are `templates2`, `schematypes` and `authorswaps`. +- The deploy is a `setcode` plus `setabi`. On-chain state is preserved and no migration action runs. +- A chain that ran the rc1 to rc3 candidates clears the `holders` table before this deploy. No action in this release can erase such a row, and the RAM it occupies is stranded. A chain coming from v1 has no `holders` rows and needs no cleanup. +- `setversion` takes `2.0.0`. +- Signers of an msig proposal check the proposal's wasm sha256 against the table above, and its packed ABI against the published `.abi`, because the chain does not validate `setabi` payloads. + +### Features + +- `redtemplmax` lowers a template's `max_supply`, never below the supply already issued. `0907db9` +- `deltemplate` removes a template that has no issued assets and erases its `templates2` row. `0907db9` +- `createtempl2` creates a template that carries mutable data and `settempldata` updates it, each change emitting `logsetdatatl`. The mutable data lives in the new `templates2` table beside the immutable `templates` row, so a reader that knows only `templates` still works. `0907db9` +- `setschematyp` records per-field media-type descriptors for a schema in the new `schematypes` table, so a consumer can tell how to render a field instead of inferring it from the field name. The action replaces the whole descriptor array, so a client reads the stored descriptors before it writes, or it writes its own inferred view back to chain. `0907db9` +- `createauswap` proposes a new collection author and `acceptauswap` or `rejectauswap` completes or cancels the handover, with pending swaps in the new `authorswaps` table. The collection's `author` changes only on acceptance, so no single party moves a collection on its own. `0907db9` +- `setrampayer` and `setlastpayer` reassign an asset's RAM payer and emit `logrampayer`. `setrampayer` moves a named asset to the caller and refunds the previous payer, and `setlastpayer` does the same for the caller's newest asset in a collection. (#19) +- The collection-authorization path reads only the bytes it needs from the collection row through a low-level partial read, instead of loading a row that can carry several KB of serialized data on every schema, template and asset action. The transfer and offer paths also stop building their failure message on the success path, since `check(cond, msg)` evaluates the message before the call. `89774e9` + +### Other changes + +- Custodial rentals are not part of v2. The implementation is preserved on the [`archive/v2-custodial-rentals`](https://github.com/atomicassets/atomicassets-contract/tree/archive/v2-custodial-rentals) branch. (#27) diff --git a/README.md b/README.md index e4b7f8e..0b3046e 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ $ make clean $ make build ``` +## Releases + +Tagged versions are published as GitHub Releases with the `atomicassets.wasm`, +`atomicassets.abi` and `SHA256SUMS` assets attached. +[RELEASING.md](RELEASING.md) covers how a release is cut and what its notes +carry, and [CHANGELOG.md](CHANGELOG.md) holds the notes for each version. + ## Testing ### Test Framework @@ -91,4 +98,9 @@ Tests are organized in directories by functionality: - `tests/interface-header/` - Interface header consumer compile check - `tests/schema-actions/` - Schema operations - `tests/template-actions/` - Template management -- `tests/transfer-offer-actions/` - Transfer and trading functionality \ No newline at end of file +- `tests/transfer-offer-actions/` - Transfer and trading functionality + +## Credits + +AtomicAssets is the work of many contributors, recorded in +[AUTHORS.md](AUTHORS.md). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..26dc1c7 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,222 @@ +# Releasing atomicassets-contract + +How a version of this contract reaches GitHub Releases. A release ends at a +rendered Release carrying `atomicassets.wasm`, `atomicassets.abi` and +`SHA256SUMS` as assets, not at the pushed tag: those checksums are what a +deployer pins and what a signer of a multi-party proposal verifies against. + +Tags are `vX.Y.Z` (`v2.0.0`), and a release candidate is `vX.Y.Z-rcN` +(`v2.0.0-rc5`). The release artifacts are built with the CDT version CI pins, +4.1.1, so a reader can rebuild the tag and get the hashes the notes name. + +## 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. Its + `### Upgrading` states the ABI compatibility with the previous stable release + (byte-identical, additive, or breaking with the migration a consumer makes) + and the `setversion` value, which is the core semver of the tag. The rows of + the checksum table are left empty here, because the build in step 2 produces + them. The entry is the editorial text of the Release, so it is written once, + in the PR that makes the change. + +2. Build the release artifacts from a clean tree and write the checksum file: + + ```sh + make clean + make release + (cd build && sha256sum atomicassets.wasm atomicassets.abi) > SHA256SUMS + ``` + + `make release` compiles with the pinned CDT and patches the ABI back to the + legacy spellings, which is what the released `.abi` carries. This + repository has no docker build path, so the release build needs the CDT + that CI installs (`CDT_VERSION` in `.github/workflows/contracts-ci.yaml`) + installed natively. The checksum file uses bare asset names, so it verifies + against the downloaded assets in step 6. Copy its two rows into the entry's + `### Upgrading` table: + + ``` + | Asset | sha256 | + | --- | --- | + | `atomicassets.wasm` | `` | + | `atomicassets.abi` | `` | + ``` + + Land that as a `chore(release): X.Y.Z` commit touching `CHANGELOG.md` + alone. The wasm and the ABI do not depend on `CHANGELOG.md`, so a build of + the tag reproduces the hashes the entry names. `SHA256SUMS` is a release + asset rather than a committed file, and it stays out of the commit. A + stable release that ships the last candidate's build has identical rows + already in the entry: tag the candidate's commit and land nothing new. + +3. Preview the body before anything is tagged: + + ```sh + scripts/release-notes.sh vX.Y.Z main + ``` + + The preview composes the body from the `CHANGELOG.md` entry at that branch + and the commits since the previous tag, and it fails when the entry is + missing. It does not check the section names, so read the preview against + the template below. Pass `origin/main` in a clone without a local `main`. + The compare link is built from the `origin` remote, so cut the release from + a clone whose `origin` is this repository, not a fork. + +4. Tag the release commit and push the tag: + + ```sh + git tag vX.Y.Z && git push origin vX.Y.Z + ``` + + A candidate is tagged `vX.Y.Z-rcN`. Push the tag before creating the + Release, because `--verify-tag` refuses a tag the remote does not have. + +5. Compose the body, read it, then create the Release with its assets: + + ```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 \ + build/atomicassets.wasm build/atomicassets.abi SHA256SUMS + ``` + + Add `--prerelease` for a `-rcN` tag, so the candidate does not become the + repository's latest Release. Add `--latest=false` when the Release is for a + tag older than the current latest one, so the latest marker does not move + backwards. With more than one release in flight, create them in ascending + version order. + +6. Verify the published Release against its own assets: + + ```sh + gh release download vX.Y.Z --dir /tmp/vX.Y.Z + (cd /tmp/vX.Y.Z && sha256sum -c SHA256SUMS) + ``` + + The body's table names the same two hashes. When either check fails, never + re-attach an asset and never move the tag on a published Release: a + consumer that pinned these hashes fails closed on any change, and a signer + may already have verified a proposal against them. Cut the next patch + version instead. + +Deploying the contract, whether by key or by multi-party proposal, and the +`setcode`, `setabi`, `setversion` and resource steps that go with it, stays with +the deployer's own procedure. The Release is what they pin and verify against. + +## 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 + +- . + +## Features + +- . (#N) + +## Bug fixes + +- . (#N) + +## Security + +- . (#N) + +## Deprecations + +- . (#N) + +## Other changes + +- . (#N) + +## Commits + +- + +Full changelog: https://github.com/atomicassets/atomicassets-contract/compare/... +``` + +The section order is breaking changes, upgrading, features, bug fixes, +security, deprecations, other changes. + +A Release body carries no credits section. Contributor and lineage credit lives +in [AUTHORS.md](./AUTHORS.md) and the README, where it is maintained once +rather than restated per version. + +`## Upgrading` is for the deployer and the integrator, and it is written against +the previous stable release rather than against the tag range the commit list +covers. For this contract it carries the checksum table of the released wasm and +ABI, the statement of how the ABI compares with the previous stable release +(byte-identical, additive, or breaking with the migration a consumer makes), the +`setversion` value, and any ordering or resource note the deploy depends on. A +candidate body may confine it to the change since the previous candidate that +has a Release, because that is the move a test deployment makes; the stable body +describes the whole move. One table is allowed here, and it lists the released +artifact checksums. Items elsewhere stay bullets. + +`## Security` carries advisories and dependency lifts, each naming its GHSA or +CVE identifier. A release with neither section leaves both 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. An `## Upgrading` item that states a + deploy fact rather than a change, such as a checksum or an ABI that has not + moved, carries no reference. +- 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]`, optionally followed by ` - YYYY-MM-DD`. Under +it comes an optional one-line summary, then the H3 sections in the order above +(`### Breaking changes`, `### Upgrading`, and the rest). A candidate tag +`vX.Y.Z-rcN` reads the `## [X.Y.Z]` entry as it stands at that tag, so a +candidate body shows the notes for the version so far and the stable body shows +the finished entry. + +## Tag ranges, candidates, 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 + candidates between them. `PREV` for a candidate tag is the nearest earlier tag + of any kind, which is usually the previous candidate. A stable tag whose only + earlier tags are candidates takes the nearest of them, so the first stable + release after a candidate line lists what it adds to the last candidate. +- Tags from the upstream v1 line count as earlier tags, so `v2.0.0` lists the + commits since `v1.2.3`. Those v1 tags carry no Release of their own. +- `## Commits` lists the whole `PREV..TAG` range, oldest first, including the + release commit. Its line count equals `git rev-list --count PREV..TAG`. +- 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 candidate tag is created with `--prerelease`, and a Release created for a + tag older than the current latest is created with `--latest=false`. + +`scripts/release-notes.sh` needs bash, git, awk and sed. Without a ref it reads +`CHANGELOG.md` at the tag rather than from the working tree, so the body +describes what the tag ships. It exits non-zero and names what is missing when +no tag is given, when the tag does not exist, when the CHANGELOG at that ref +carries no entry for the version, and when no earlier tag exists. 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" ]