From c40c777f3b2f79cf38255cef4adbdf6123ac3a89 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Mon, 17 Aug 2026 16:54:15 -0400 Subject: [PATCH] docs: add the release checklist, the notes composer and a changelog Releases were cut by hand and each Release body was written in its own shape, so a deployer had to hunt for the checksums, the ABI compatibility statement and the deploy notes in a different place every time, and attribution repeated per release. RELEASING.md fixes the procedure: the CHANGELOG entry rides in the feature PR, the release build writes SHA256SUMS with bare asset names and its two rows land in the entry as the release commit, a preview runs before the tag, and the Release attaches the wasm, the abi and SHA256SUMS. scripts/release-notes.sh (the composer shared with atomicassets-api, copied unchanged with its paired test that CI now runs) composes the body from the entry at the tag plus the commit list since the previous release of the same kind and the compare link. CHANGELOG.md starts at 2.0.0 with the entry that matches the live v2.0.0 Release; the README points at both files and at AUTHORS.md, which carries the lineage the Release bodies used to repeat. --- .github/workflows/contracts-ci.yaml | 7 + AUTHORS.md | 17 ++ CHANGELOG.md | 55 ++++++ README.md | 12 ++ RELEASING.md | 232 ++++++++++++++++++++++ scripts/release-notes.sh | 100 ++++++++++ scripts/release-notes.test.sh | 293 ++++++++++++++++++++++++++++ 7 files changed, 716 insertions(+) create mode 100644 AUTHORS.md create mode 100644 CHANGELOG.md create mode 100644 RELEASING.md create mode 100755 scripts/release-notes.sh create mode 100755 scripts/release-notes.test.sh diff --git a/.github/workflows/contracts-ci.yaml b/.github/workflows/contracts-ci.yaml index a7063e2..283df00 100644 --- a/.github/workflows/contracts-ci.yaml +++ b/.github/workflows/contracts-ci.yaml @@ -41,6 +41,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/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..4e5bc15 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,17 @@ +# Authors and credits + +AtomicMarket is part of the AtomicAssets NFT ecosystem, created by +[Pink Network](https://pink.gg). Stewardship of AtomicMarket and the related +software passed from Pink Network to Spielworks, and then to FACINGS, the +current owner and maintainer. The v1 contract was written by +**Jona Wilmsmann (Pink Network)**. + +## AtomicMarket v2 + +Most of the v2 contract was authored by +**t-break ([@on-a-t-break](https://github.com/on-a-t-break))**, and reached +this repository through the `on-a-t-break/v2/integration-2` branch, whose +commits carry that authorship. + +The v2 review, the hardening changes, the XPR port, the VeRT test migration and +the releases are the work of the FACINGS team. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6a14680 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +Notable changes to the AtomicMarket contract. This file starts at 2.0.0; the +releases published before it live in +[GitHub Releases](https://github.com/atomicassets/atomicmarket-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 AtomicMarket v2 contract: royalty splits, single-asset listings, and a collection fee charged at execution time. + +### Breaking changes + +- A sale, auction or buyoffer references exactly one asset, and `announcesale`, `announceauct` and `createbuyo` reject more than one. Listing several assets in one transaction covers what bundles were used for, and single-asset rows keep per-asset attribution of the collection fee and the royalty split exact. (#1) +- A pre-v2 row holding more than one asset cancels when it is touched, rather than migrating. `purchasesale` cancels the sale and charges the buyer nothing, a bid or claim on a bundle auction dissolves it with the bid refunded and the assets returned, and `acceptbuyo` refunds the buyer. `cancelsale` and `cancelauct` on a bundle may be called by anyone, and activating a bundle through the `sale` offer memo or the `auction` transfer memo aborts. (#1) +- A partially claimed bundle auction finishes through the normal claim actions, because one side was already served. The collection fee goes to the author in full and no royalty logs are emitted. (#2) +- Settlement reads the collection's `market_fee` live from AtomicAssets at execution time, not the value stored when the listing was created. A fee change by the author, up or down within the 15% cap, applies at once to every existing listing, and the listing row's `collection_fee` is informational only. The buyer always pays the listed price and only the split between seller and collection moves, so a front end shows the live fee at the point of sale. (#3) + +### Upgrading + +| Asset | sha256 | +| --- | --- | +| `atomicmarket.wasm` | `5016d9560574cce18f511de41d4ba1e81c4d30526c4452f0f70c3e59d64e7ed3` | +| `atomicmarket.abi` | `e1b480faeb6f59f8f474af28677907a25d0d58e455d5a951f2315a87012177a9` | + +- 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.3.3: nothing is removed and no existing struct changes shape. The published ABI is the legacy-compat build, where `vector` fields render as `uint8[]`, so a v1 reader 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 51 actions and 12 tables. The existing tables (`sales`, `auctions`, `buyoffers`, `tbuyoffers`, `config`, `balances`, `marketplaces`, `bonusfees`, counters) keep their layout, and the added tables are `royaltyconf`, `royaltytemp` and `royaltyattr`. +- Deploy AtomicAssets v2 on the chain first. Settlement reads the AtomicAssets `templates2` table for royalty attribute matching, and the collections row for the live market fee. +- A chain that does not yet run template buyoffers also gains `createtbuyo`, `canceltbuyo`, `fulfilltbuyo`, `lognewtbuyo` and the `tbuyoffers` table with this upgrade. +- The deploy is a `setcode` plus `setabi`. On-chain state is preserved, and existing single-asset sales, auctions and buyoffers keep working. +- `setversion` takes `2.0.0`. +- These bytes ran on the WAX testnet and jungle4 as rc2, where the on-chain code hash matched the table above. That was the first AtomicMarket v2 deployment on any chain. +- 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 + +- By default the collection fee goes to the collection author, and an author can instead split it across weighted categories: a global founders list, per-template recipient lists, and attribute rules that match a `(field, value)` on the asset such as `rarity = legendary`. Configuration lives in the new `royaltyconf`, `royaltytemp` and `royaltyattr` tables. (#1) +- Settlement emits `logroyfound`, `logroytempl`, `logroyattr` and `logroydust`, whose amounts sum exactly to the collection fee, so an indexer records the final per-recipient amounts without reimplementing the split. These actions notify no account, so read them from action traces, and a `logroyattr` rule id is never reused. Payouts accrue to balances and are claimed with `withdraw`, and nothing is transferred inline, so a recipient contract cannot block a collection's settlements. (#1) +- `setdefmktcr` redirects the empty-name default marketplace's fee recipient at runtime, and `migratebal` merges accumulated balances, so one binary runs on a chain where the seeded `fees.atomic` account does not exist. `migratebal` rejects `from == to`, which would otherwise double a balance. (#7) +- Tables are constructed lazily per action, the config singleton is deserialized at most once per action, the AtomicAssets collections row is read through a size-capped partial read that takes only the author and the market fee, and notification handlers bind with `[[eosio::on_notify]]`. (#1) + +### Bug fixes + +- `assertsale`, `assertauct` and `acceptbuyo` compare asset lists with the four-iterator `std::is_permutation`, which checks the lengths and cannot read past the shorter list. `6da1ed7` +- Settlement re-asserts the execution-time fee between 0 and 15 percent, because a negative double cast to `uint64_t` is undefined behavior, and `acceptbuyo` and `fulfilltbuyo` check the AtomicAssets offers table before reading its last row. `2b0338f` + +### Other changes + +- `setmarketfee` and `addbonusfee` reject a fee configuration whose fees plus the maximum collection fee exceed the sale price, and `internal_payout_sale` asserts a positive seller payout as the runtime backstop. (#4) +- Custodial rentals are not part of v2. The implementation is preserved on the [`archive/v2-custodial-rentals`](https://github.com/atomicassets/atomicmarket-contract/tree/archive/v2-custodial-rentals) branch. (#13) diff --git a/README.md b/README.md index 8367fcf..9d68700 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,15 @@ AtomicMarket is a marketplace to sell and auction [AtomicAssets](https://github. - **Collection fees** Collections can define a market fee between 0 and 15% in the AtomicAssets contract. This fee is respected by the AtomicMarket and paid to the authors of the collection. + +## Releases + +Tagged versions are published as GitHub Releases with the `atomicmarket.wasm`, +`atomicmarket.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. + +## Credits + +AtomicMarket 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..23b3ba3 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,232 @@ +# Releasing atomicmarket-contract + +How a version of this contract reaches GitHub Releases. A release ends at a +rendered Release carrying `atomicmarket.wasm`, `atomicmarket.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-rc2`). 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 atomicmarket.wasm atomicmarket.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. It needs the + CDT installed natively. Without a native install, `bash build.sh` compiles + with the same pinned CDT in the `antelope-cdt` docker image, and + `python3 scripts/patch-abi.py build/atomicmarket.abi` applies the same + patch: + + ```sh + make clean + bash build.sh + python3 scripts/patch-abi.py build/atomicmarket.abi + (cd build && sha256sum atomicmarket.wasm atomicmarket.abi) > SHA256SUMS + ``` + + 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 | + | --- | --- | + | `atomicmarket.wasm` | `` | + | `atomicmarket.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/atomicmarket.wasm build/atomicmarket.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/atomicmarket-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.3.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" ]