From 05178d1c9e33b7d696572460488c796fac4ac1fa Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Thu, 29 Oct 2026 23:11:01 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9A=91=20fix:=20sdk:=20run=20the=20ta?= =?UTF-8?q?rget=20version's=20repository=20updater?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: Older recipes cannot change during their own update - Impact: Run the target updater and resume Copier conflicts through bootstrap --- README.md | 2 + docs/backlog/COR-103-verification-loop.md | 2 +- docs/north-star/architecture.md | 2 +- .../contract/extension-repository.md | 33 ++- extensions/skill/nicely-extensions/SKILL.md | 16 ++ scripts/template-test.sh | 31 ++- scripts/template-update-test.sh | 179 ++++++++---- sdk/extensionrepo/cmd/extensionrepo/main.go | 2 +- sdk/extensionrepo/install.go | 2 +- sdk/extensionrepo/program.go | 13 +- sdk/extensionrepo/recipes.go | 48 ++++ sdk/extensionrepo/update.go | 254 ++++++++++++++++++ template/AGENTS.md.jinja | 17 +- template/justfile | 82 +----- 14 files changed, 548 insertions(+), 135 deletions(-) create mode 100644 sdk/extensionrepo/recipes.go create mode 100644 sdk/extensionrepo/update.go diff --git a/README.md b/README.md index 9a7be4a2..ce5d7482 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Omit `name` for a whole-domain repository. Both use the SDK test kit and the same update recipe. The kit runs manifest examples with scratch fixtures and checks each answer. It uses dry runs where available and prints skip reasons in plain `just check` output. +`just update VERSION` runs the target version's updater. +Older repositories use the bootstrap in [Update an extension](docs/north-star/contract/extension-repository.md#update-an-extension). Further work lives in [docs hub](docs/backlog/XT-104-docs-hub.md), [Canadian French](docs/backlog/COR-102-canadian-french.md), [Markdown view](docs/backlog/XT-103-markdown-view.md), and [video and image jobs](docs/backlog/XT-602-video-image-jobs.md). diff --git a/docs/backlog/COR-103-verification-loop.md b/docs/backlog/COR-103-verification-loop.md index 70d82db9..604f4323 100644 --- a/docs/backlog/COR-103-verification-loop.md +++ b/docs/backlog/COR-103-verification-loop.md @@ -30,7 +30,7 @@ Each check fails first on one named past mistake. | COR-103-T5 | Observe declared codes | agent | COR-103-T2, COR-103-T3 | done | | COR-103-T6 | Signal and refuses commands | agent | COR-103-T3 | done | | COR-103-T7 | One install and uninstall path | agent | - | done | -| COR-103-T8 | Update through the target updater | agent | COR-103-T7 | todo | +| COR-103-T8 | Update through the target updater | agent | COR-103-T7 | done | | COR-103-T9 | Sign off an isolated checkout | agent | COR-103-T7 | done | | COR-103-T10 | Release with a receipt | agent | COR-103-T7, COR-103-T9 | todo | | COR-103-T11 | macOS job in the template | agent | - | done | diff --git a/docs/north-star/architecture.md b/docs/north-star/architecture.md index 01b83f45..1f9265bb 100644 --- a/docs/north-star/architecture.md +++ b/docs/north-star/architecture.md @@ -60,7 +60,7 @@ Each part has one owner: one package and one spec section. | 3 | Extension host | Discovery, manifests, dispatch, answer checks, and grants | `internal/cli/` | [Extensions](contract/extensions.md) | | 3 | Core commands | Help, completion, describe, doctor, auth, run, and tap, and core's catalog | `internal/cli/` | [core-spec.md](core-spec.md) | | 2 | Extension entry | One entry point that serves an extension as `ncly-` or bundled, the export of its sidecars, and its embedded workers | `sdk/extension/` | [SDK](contract/sdk.md) | -| 2 | Extension repository | Staged install and uninstall | `sdk/extensionrepo/` | [Extension repository](contract/extension-repository.md) | +| 2 | Extension repository | Repository checks, updates, install and uninstall | `sdk/extensionrepo/` | [Extension repository](contract/extension-repository.md) | | 2 | Wrap helper | One program that a handler runs, such as a worker: its check, input, environment, captured output, and failures | `sdk/wrap/` | [Wrapped programs](contract/wrap.md) | | 2 | Test kit | The tests of an extension repository: its checks, its scenarios, and an install into scratch folders | `sdk/extensiontest/` | [Test kit](contract/test-kit.md) | | 2 | Command line | Declarations turned into commands, global flags, help pages, forms for missing values, and answers printed | `sdk/command/` | [Command descriptions](contract/command-descriptions.md), [Output](contract/output.md) | diff --git a/docs/north-star/contract/extension-repository.md b/docs/north-star/contract/extension-repository.md index 0eb2647d..2ac24c59 100644 --- a/docs/north-star/contract/extension-repository.md +++ b/docs/north-star/contract/extension-repository.md @@ -38,7 +38,7 @@ The repository tests itself through the [test kit](test-kit.md). Its `justfile` - `just install` builds the executable and exports its sidecars from that build. It replaces the previous install of the slug in the extensions folder of [Discovery](discovery.md), the executable last - `just uninstall` removes the install of the slug from that folder, the executable first. With nothing installed, it says so and exits 0 - `just acceptance [fixture]` runs `acceptance.sh` as an agent would, with `--json` and `--no-input`. It installs the extension and the `ncly` of the pinned Nicely version into scratch XDG folders. It works on a copy of the fixture and checks that the fixture keeps its checksum. Without a fixture, it makes an input -- `just update ` moves the repository to another Nicely version: the template first, then the SDK, on a clean Git tree. Copier leaves `go.mod`, which holds the dependencies of the author. Copier can leave a conflict as markers, a `.rej` file, or an unmerged index entry, and still exit 0. On a conflict, the recipe stops before `go get` and prints the commands that finish the update. Otherwise, `go get` moves the Nicely pin, and the recipe copies each `replace` of Nicely's `go.mod` and drops each one that Nicely dropped. It never resets, stashes, or commits +- `just update ` runs the target version's updater on a clean Git tree, with the template before the SDK. [Update an extension](#update-an-extension) defines bootstrap and conflict recovery - `just contract` prints the folder of this contract in the Go module of the pinned Nicely version, so an agent reads the exact rules offline `just install` grants no key. A human grants one after reviewing the extension, as [Grants](grants.md) defines. @@ -69,3 +69,34 @@ Once per suite, a separate scratch install holds only the extension under test. The kit installs and uninstalls that extension there. Then `ncly describe --json` must exit 2 with `NOT_FOUND`. Extensions that a scenario adds cannot affect this lifecycle check. + +## Update an extension + +Each template recipe has one command line. +The SDK program owns `check`, `contract`, and `update`, as well as install and uninstall. +The update recipe downloads the target Nicely module, then runs its `sdk/extensionrepo/cmd/extensionrepo` program with `go -C`. +This uses the target's updater and dependency replacements, even when the repository pins an older SDK. +`go run package@version` cannot run a module that has `replace` directives. + +An older recipe cannot change during its own run. +For a repository rendered before this recipe, the bootstrap runs the target program directly. +Replace VERSION with the target tag, such as `v0.24.0`, from the extension repository: + +```sh +go -C "$(go mod download -json github.com/pascalandy/nicely@VERSION | sed -n 's/^[[:space:]]*"Dir": "\(.*\)",$/\1/p')" run ./sdk/extensionrepo/cmd/extensionrepo update VERSION "$PWD" +``` + +The updater accepts only `vX.Y.Z` tags and an absolute repository path. +It first requires a committed, clean Git tree. +Copier keeps the author's `go.mod` and applies the target template. +The updater checks for conflict markers, `.rej` files, and unmerged index entries before changing dependencies. +On a conflict, it prints each file and the bootstrap command with `--continue` appended. + +Resolve each conflict and mark its file with `git add`. +Remove each `.rej` file, then run the printed command. +Continuation requires the target tag in `.copier-answers.yml` and no remaining conflicts. +It skips Copier, moves the Nicely pin, reconciles the previous and target module replacements, and runs `go mod tidy`. +Both update paths finish with `just check`. +The updater preserves unrelated dependencies and replacements. +It never resets, stashes, or commits. +If Copier fails, its output gives restoration commands and the bootstrap command to retry. diff --git a/extensions/skill/nicely-extensions/SKILL.md b/extensions/skill/nicely-extensions/SKILL.md index 01ed3d87..a3f6ebac 100644 --- a/extensions/skill/nicely-extensions/SKILL.md +++ b/extensions/skill/nicely-extensions/SKILL.md @@ -34,6 +34,22 @@ The domain names a family of work, such as `video`, and the optional name identi Neither names the wrapped tool. For a whole-domain extension, omit `--data name=NAME` and both `-NAME` suffixes. +## Update an extension repository + +Run `just update VERSION` on a clean, committed Git tree. +Replace VERSION with the target Nicely tag, such as `v0.24.0`. + +For an older repository whose update recipe holds a script, bootstrap from its root: + +```sh +go -C "$(go mod download -json github.com/pascalandy/nicely@VERSION | sed -n 's/^[[:space:]]*"Dir": "\(.*\)",$/\1/p')" run ./sdk/extensionrepo/cmd/extensionrepo update VERSION "$PWD" +``` + +On a conflict, resolve each file and mark it with `git add`. +Remove each `.rej` file, then run the printed bootstrap command with `--continue`. +Success ends with `just check`. +[Update an extension](https://github.com/pascalandy/nicely/blob/main/docs/north-star/contract/extension-repository.md#update-an-extension) holds the recovery rules. + ## Remove an extension `just install` puts an extension in the extensions folder: `nicely/extensions` in the data folder, such as `~/.local/share/nicely/extensions`. diff --git a/scripts/template-test.sh b/scripts/template-test.sh index 492d62ab..646eafe2 100644 --- a/scripts/template-test.sh +++ b/scripts/template-test.sh @@ -5,7 +5,7 @@ # folders. A clone at a release tag installs with the commands of its # README.md. just update then carries a customized repository to the next # template revision, and keeps the edits and the dependencies of its author. On -# a conflict, it stops before any go command. The test runs on macOS and Linux, +# a conflict, it stops before dependency changes. The test runs on macOS and Linux, # with the bash 3.2 of macOS, and uses no network once uv holds the pinned # Copier. set -Eeuo pipefail @@ -21,6 +21,7 @@ fct_revision() { git -C "${src}" add -A git -C "${src}" commit -qm "template ${1}" git -C "${src}" tag "${1}" + fct_proxy_revision "${src}" "${1}" "${proxy}" } # fct_commit commits every change of the rendered repository, as its author. @@ -74,10 +75,9 @@ fct_repository() { src="${scratch}/source" repo="${scratch}/ncly-${slug}" data="${scratch}/data" extensions="${data}/nicely/extensions" mkdir -p "${scratch}" - # The template source holds the working tree of this checkout, changes included, tagged as a version. + # The module proxy and Copier source both hold this checkout, with its changes. mkdir "${src}" - cp "${root}/copier.yml" "${src}/" - cp -R "${root}/template" "${src}/template" + fct_copy_source "${src}" git -C "${src}" init -q fct_revision v0.0.1 "${copier[@]}" copy --quiet --vcs-ref v0.0.1 --defaults --data "domain=${domain}" --data "module=example.com/ncly-${slug}" "$@" "${src}" "${repo}" @@ -138,8 +138,19 @@ fct_repository() { } fct_main() { - local root pin scratch copier template_src + local root pin scratch copier template_src proxy cache_proxy root="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd -P)" + # Copied recipes only wire commands to the SDK or an owned script. + awk ' + /^[^[:space:]#].*:[[:space:]]*$/ { recipe = $1; sub(/:$/, "", recipe); lines = 0; reported = 0 } + /^[[:space:]]+#!/ || /^[[:space:]]+[^[:space:]#]/ { + if ((++lines > 1 || $1 ~ /^#!/) && !reported++) { + print "template-test.sh: recipe " recipe " must have one command line" > "/dev/stderr" + failed = 1 + } + } + END { exit failed } + ' "${root}/template/justfile" # shellcheck source=scripts/template-update-test.sh source "${root}/scripts/template-update-test.sh" # shellcheck source=scripts/template-input-test.sh @@ -154,17 +165,23 @@ fct_main() { pin="$(sed -n 's/^copier := "\(.*\)"$/\1/p' "${root}/template/justfile")" [[ "${pin}" == "uv tool run copier@"* ]] || fct_fail "template/justfile pins no Copier" grep -qF -- "${pin}" "${root}/extensions/skill/nicely-extensions/SKILL.md" || fct_fail "the nicely-extensions skill does not give ${pin}, which template/justfile pins" - awk '$1 == "replace" && !($3 == "=>" && NF == 5) { exit 1 }' "${root}/go.mod" || - fct_fail "write each replace of Nicely's go.mod as one line, replace X => Y V: just update of template/justfile copies only that form" scratch="$(mktemp -d "${TMPDIR:-/tmp}/ncly-template-test.XXXXXXXX")" readonly NCLY_TEMPLATE_TEST_ROOT="${scratch}" trap 'rm -rf "${NCLY_TEMPLATE_TEST_ROOT}"' EXIT # Git, in Copier too, takes its identity from this file, without the signing of the user. printf '[user]\n\tname = template-test\n\temail = template-test@example.com\n[commit]\n\tgpgsign = false\n[tag]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n' >"${scratch}/gitconfig" export GIT_CONFIG_GLOBAL="${scratch}/gitconfig" GIT_CONFIG_NOSYSTEM=1 + proxy="${scratch}/proxy/github.com/pascalandy/nicely/@v" + mkdir -p "${proxy}" + cache_proxy="file://$(go env GOMODCACHE)/cache/download" + export GOPROXY="file://${scratch}/proxy,${cache_proxy},${GOPROXY:-https://proxy.golang.org,direct}" + export GOMODCACHE="${scratch}/modcache" GONOSUMDB=github.com/pascalandy/nicely + # Go normally makes cached module folders read-only, which prevents scratch cleanup. + export GOFLAGS="${GOFLAGS:-} -modcacherw" read -r -a copier <<<"${pin}" fct_repository "${scratch}/named" video convert video-convert 'video convert' --data name=convert fct_repository "${scratch}/whole" probe '' probe 'probe inspect' + fct_test_bootstrap "${scratch}/bootstrap" "${copier[@]}" template_src="${scratch}/whole/source" fct_test_import_domains "${template_src}" "${scratch}" "${root}" "${copier[@]}" fct_test_release_url "${template_src}" "${scratch}" "${copier[@]}" diff --git a/scripts/template-update-test.sh b/scripts/template-update-test.sh index f2e765f4..ed13389f 100644 --- a/scripts/template-update-test.sh +++ b/scripts/template-update-test.sh @@ -1,88 +1,171 @@ #!/usr/bin/env bash -# Sourced by template-test.sh to exercise updates of a rendered repository. +# Sourced by template-test.sh to exercise target-version updates and the old-recipe bootstrap. + +fct_copy_source() { + git -C "${root}" ls-files --cached --others --exclude-standard -z | + tar -C "${root}" -cf - --null -T - | tar -C "${1}" -xf - +} + +# Publish a scratch tag to the file proxy, without touching the user's module cache. +fct_proxy_revision() { + local src="${1}" version="${2}" proxy="${3}" + git -C "${src}" show "${version}:go.mod" >"${proxy}/${version}.mod" + printf '{"Version":"%s","Time":"2000-01-01T00:00:00Z"}\n' "${version}" >"${proxy}/${version}.info" + git -C "${src}" archive --format=zip --prefix="github.com/pascalandy/nicely@${version}/" "${version}" -- . ':!tools' ':!testdata/extensions/standalone' ':!testdata/extensions/standalone-named' >"${proxy}/${version}.zip" + printf '%s\n' "${version}" >>"${proxy}/list" +} + +# Run the bootstrap exactly as the bundled skill documents it. +fct_bootstrap() { + local version="${1}" mode="${2:-}" command status=0 + command="$(sed -n '/^go -C .* update VERSION /p' "${root}/extensions/skill/nicely-extensions/SKILL.md")" + [[ -n "${command}" && "${command}" != *$'\n'* ]] || fct_fail 'the skill must document one bootstrap command' + command="${command//VERSION/${version}}" + (cd "${repo}" && bash -c "${command}${mode:+ --continue}") >"${scratch}/update.out" 2>&1 || status=$? + return "${status}" +} fct_test_update() { local src="${1}" repo="${2}" scratch="${3}" root="${4}" status dependency lipgloss - # The author edits two files, and the next template revision changes one of them elsewhere. fct_edit "${repo}/vision.md" "" "Author's note: kept through each update." fct_edit "${repo}/AGENTS.md" "# AGENTS.md" "# AGENTS.md of the author" if [[ -z "$(sed -n '/^\[/q; /^name = /p' "${repo}/manifest.toml")" ]]; then - # Simulate a repository rendered before name existed. sed '/^name:/d' "${repo}/.copier-answers.yml" >"${repo}/answers.new" mv "${repo}/answers.new" "${repo}/.copier-answers.yml" fi - # The author adds a direct dependency, as #347 U1 found, so the require line of the template becomes a block. - # golang.org/x/text is in the module graph of Nicely, so go mod tidy needs no network. + # The author's direct dependency and unrelated replacement survive the update. mkdir "${repo}/internal/words" printf 'package words\n\nimport _ "golang.org/x/text/language"\n' >"${repo}/internal/words/words.go" go -C "${repo}" mod tidy dependency="$(grep -E '^[[:space:]]golang\.org/x/text v[^ ]+$' "${repo}/go.mod")" || - fct_fail "go mod tidy does not require golang.org/x/text as a direct dependency" - # The author's replace of Lip Gloss differs from Nicely's. It names the folder of the fork in the module cache, so it builds offline. + fct_fail 'go mod tidy does not require golang.org/x/text directly' lipgloss="$(grep '^replace charm.land/lipgloss/v2 ' "${root}/go.mod")" || - fct_fail "Nicely's go.mod replaces no Lip Gloss: remove this case with the replace of template/go.mod.jinja" + fct_fail 'Nicely no longer replaces Lip Gloss: revise this replacement case' go -C "${repo}" mod edit -replace "charm.land/lipgloss/v2=$(go -C "${repo}" list -m -f '{{.Dir}}' charm.land/lipgloss/v2)" - # The go.mod of Nicely v0.0.1 also replaces example.com/dropped, which v0.0.2 no longer replaces. just update reads only that go.mod. + go -C "${repo}" mod edit -replace example.com/author=example.com/author@v1.0.0 mkdir "${scratch}/nicely-v0.0.1" { cat "${root}/go.mod" printf 'replace example.com/dropped => example.com/dropped v1.0.0\n' } >"${scratch}/nicely-v0.0.1/go.mod" - # A TMPDIR of macOS ends with a slash, and // starts a comment in go.mod, so the path is quoted. printf 'replace github.com/pascalandy/nicely v0.0.1 => "%s"\nreplace example.com/dropped => example.com/dropped v1.0.0\n' "${scratch}/nicely-v0.0.1" >>"${repo}/go.mod" - fct_commit "customized" + fct_commit customized fct_edit "${src}/template/AGENTS.md.jinja" "" "This line comes from template v0.0.2." fct_revision v0.0.2 - # just update ends with just check, so its exit code 0 proves the check of the rendered repository. fct_update v0.0.2 || { cat "${scratch}/update.out" >&2 - git -C "${repo}" status --short >&2 - fct_fail "just update v0.0.2 failed on a customized repository" + fct_fail 'just update v0.0.2 failed' } - fct_said "go run ./internal/export test" + fct_said 'go test -count=1 ./...' fct_has "${repo}/vision.md" "Author's note: kept through each update." - fct_has "${repo}/AGENTS.md" "# AGENTS.md of the author" - fct_has "${repo}/AGENTS.md" "This line comes from template v0.0.2." - fct_has "${repo}/.copier-answers.yml" "_commit: v0.0.2" - [[ -z "$(git -C "${repo}" diff --name-only --diff-filter=U)" ]] || - fct_fail "just update v0.0.2 left unmerged files: $(git -C "${repo}" diff --name-only --diff-filter=U)" + fct_has "${repo}/AGENTS.md" '# AGENTS.md of the author' + fct_has "${repo}/AGENTS.md" 'This line comes from template v0.0.2.' + fct_has "${repo}/.copier-answers.yml" '_commit: v0.0.2' fct_has "${repo}/go.mod" "${dependency}" - [[ "$(go -C "${repo}" list -m -f '{{.Version}}' github.com/pascalandy/nicely)" == v0.0.2 ]] || - fct_fail "just update v0.0.2 did not move the Nicely pin of go.mod to v0.0.2" + [[ "$(go -C "${repo}" list -m -f '{{.Version}}' github.com/pascalandy/nicely)" == v0.0.2 ]] || fct_fail 'the SDK pin did not move' fct_has "${repo}/go.mod" "replace github.com/pascalandy/nicely => ${root}" fct_has "${repo}/go.mod" "${lipgloss}" - if grep -qF example.com/dropped "${repo}/go.mod"; then fct_fail "just update kept the replace of example.com/dropped, which Nicely dropped"; fi - fct_commit "updated to v0.0.2" + fct_has "${repo}/go.mod" 'replace example.com/author => example.com/author v1.0.0' + if grep -qF example.com/dropped "${repo}/go.mod"; then fct_fail 'update kept a replacement that Nicely dropped'; fi + fct_commit 'updated to v0.0.2' - # The author and template v0.0.3 change the same line. The stub go logs each call, so a go get after the conflict shows. - fct_edit "${repo}/AGENTS.md" "This line comes from template v0.0.2." "The author rewrote this line." - fct_commit "rewritten" - fct_edit "${src}/template/AGENTS.md.jinja" "This line comes from template v0.0.2." "Template v0.0.3 rewrote this line." + # Copier changes the same line as the author. No dependency file can change before resolution. + fct_edit "${repo}/AGENTS.md" 'This line comes from template v0.0.2.' 'The author rewrote this line.' + fct_commit rewritten + fct_edit "${src}/template/AGENTS.md.jinja" 'This line comes from template v0.0.2.' 'Template v0.0.3 rewrote this line.' fct_revision v0.0.3 - mkdir "${scratch}/bin" - printf '#!/bin/sh\necho "go $*" >>"%s/go.log"\nexit 1\n' "${scratch}" >"${scratch}/bin/go" - chmod 755 "${scratch}/bin/go" + cp "${repo}/go.mod" "${scratch}/before.mod" + cp "${repo}/go.sum" "${scratch}/before.sum" status=0 - PATH="${scratch}/bin:${PATH}" fct_update v0.0.3 || status=$? - [[ "${status}" == 1 ]] || fct_fail "just update v0.0.3 exited with ${status} on a conflict, want 1" - [[ ! -e "${scratch}/go.log" ]] || fct_fail "just update ran go after a conflict: $(cat "${scratch}/go.log")" - fct_has "${repo}/AGENTS.md" "<<<<<<< before updating" - fct_said " AGENTS.md" - fct_said " go get github.com/pascalandy/nicely@v0.0.3" - fct_said " go mod tidy" - fct_said " just check" - - # On a Git tree with changes, just update stops before Copier. + fct_update v0.0.3 || status=$? + [[ "${status}" == 1 ]] || fct_fail "just update exits ${status} on a conflict, want 1" + cmp "${scratch}/before.mod" "${repo}/go.mod" || fct_fail 'update changed go.mod on a conflict' + cmp "${scratch}/before.sum" "${repo}/go.sum" || fct_fail 'update changed go.sum on a conflict' + fct_has "${repo}/AGENTS.md" '<<<<<<< before updating' + fct_said ' AGENTS.md' + grep -q -- '--continue$' "${scratch}/update.out" || fct_fail 'update printed no continuation command' status=0 fct_update v0.0.3 || status=$? - [[ "${status}" == 1 ]] || fct_fail "just update exited with ${status} on a Git tree with changes, want 1" - fct_said "update: the update needs a clean Git tree. Commit your changes, then run just update v0.0.3 again" - - # When Copier fails, just update says how to return to the commit before the update. + [[ "${status}" == 1 ]] || fct_fail 'update accepted a dirty tree' + fct_said 'update: the update needs a clean Git tree. Commit your changes, then run just update v0.0.3 again' + # The scratch repository alone returns to its committed state for the Copier failure case. git -C "${repo}" reset -q --hard status=0 - fct_update v9.9.9 || status=$? - [[ "${status}" == 1 ]] || fct_fail "just update v9.9.9 exited with ${status} for a version that does not exist, want 1" + # The target module exists, but this Copier source lacks its tag. + git -C "${src}" tag -d v0.0.3 >/dev/null + fct_update v0.0.3 || status=$? + [[ "${status}" == 1 ]] || fct_fail 'update accepted a missing Copier tag' fct_said " git restore --source=$(git -C "${repo}" rev-parse HEAD) --staged --worktree -- ." - fct_said " just update v9.9.9" + grep -q 'run ./sdk/extensionrepo/cmd/extensionrepo update v0.0.3 ' "${scratch}/update.out" || fct_fail 'Copier failure printed no bootstrap retry' +} + +fct_test_bootstrap() { + local scratch="${1}" src="${1}/source" repo version=v0.99.0 status recovery copier + shift + copier=("$@") + mkdir -p "${src}" + # The old render uses the authentic v0.23.2 template, including its copied updater. + git -C "${root}" archive v0.23.2 | tar -C "${src}" -xf - + git -C "${src}" init -q + fct_revision v0.23.2 + repo="${scratch}/untouched old render" + "${copier[@]}" copy --quiet --vcs-ref v0.23.2 --defaults --data domain=probe --data module=example.com/ncly-probe "${src}" "${repo}" + git -C "${repo}" init -q + fct_commit 'untouched v0.23.2 render' + # Only the source advances. Nothing in the rendered repository changes before bootstrap. + fct_copy_source "${src}" + printf '\nreplace example.com/target => example.com/target v1.0.0\n' >>"${src}/go.mod" + fct_revision "${version}" + fct_bootstrap "${version}" || { + cat "${scratch}/update.out" >&2 + fct_fail 'the untouched v0.23.2 bootstrap failed' + } + fct_has "${repo}/.copier-answers.yml" "_commit: ${version}" + fct_has "${repo}/go.mod" 'replace example.com/target => example.com/target v1.0.0' + [[ "$(go -C "${repo}" list -m -f '{{.Version}}' github.com/pascalandy/nicely)" == "${version}" ]] || fct_fail 'bootstrap did not move the SDK pin' + fct_said 'go test -count=1 ./...' + printf 'untouched v0.23.2 bootstrap passes through target %s and reconciles replacements\n' "${version}" + + # A second old render plants a real Copier conflict before the same bootstrap. + repo="${scratch}/conflicted old render" + "${copier[@]}" copy --quiet --vcs-ref v0.23.2 --defaults --data domain=probe --data module=example.com/ncly-probe "${src}" "${repo}" + fct_edit "${repo}/AGENTS.md" '# AGENTS.md' '# AGENTS.md of the author' + git -C "${repo}" init -q + fct_commit 'author edits on v0.23.2' + fct_edit "${src}/template/AGENTS.md.jinja" '# AGENTS.md' '# AGENTS.md of the target' + version=v0.99.1 + fct_revision "${version}" + cp "${repo}/go.mod" "${scratch}/before.mod" + status=0 + fct_bootstrap "${version}" || status=$? + [[ "${status}" == 1 ]] || fct_fail "bootstrap exits ${status} on a Copier conflict, want 1" + fct_said ' AGENTS.md' + cmp "${scratch}/before.mod" "${repo}/go.mod" || fct_fail 'bootstrap changed dependencies before conflict resolution' + recovery="$(sed -n '/^ go -C /s/^ //p' "${scratch}/update.out")" + [[ "${recovery}" == *' --continue' ]] || fct_fail 'bootstrap printed no continuation command' + # Markers still count after staging removes the unmerged index entries. + git -C "${repo}" add AGENTS.md + status=0 + (cd "${repo}" && bash -c "${recovery}") >"${scratch}/update.out" 2>&1 || status=$? + [[ "${status}" == 1 ]] || fct_fail 'continuation accepted staged conflict markers' + fct_said ' AGENTS.md' + fct_edit "${repo}/AGENTS.md" '<<<<<<< before updating' '' + fct_edit "${repo}/AGENTS.md" '=======' '' + fct_edit "${repo}/AGENTS.md" '>>>>>>> after updating' '' + git -C "${repo}" add AGENTS.md + printf 'unresolved\n' >"${repo}/AGENTS.md.rej" + status=0 + (cd "${repo}" && bash -c "${recovery}") >"${scratch}/update.out" 2>&1 || status=$? + [[ "${status}" == 1 ]] || fct_fail 'continuation accepted a .rej file' + fct_said ' AGENTS.md.rej' + mv "${repo}/AGENTS.md.rej" "${scratch}/resolved.rej" + (cd "${repo}" && bash -c "${recovery}") >"${scratch}/update.out" 2>&1 || { + cat "${scratch}/update.out" >&2 + fct_fail 'the printed continuation command failed' + } + fct_has "${repo}/.copier-answers.yml" "_commit: ${version}" + fct_has "${repo}/go.mod" 'replace example.com/target => example.com/target v1.0.0' + [[ "$(go -C "${repo}" list -m -f '{{.Version}}' github.com/pascalandy/nicely)" == "${version}" ]] || fct_fail 'continuation did not move the SDK pin' + fct_said 'go test -count=1 ./...' + printf 'v0.23.2 bootstrap recovers from Copier conflict, staged markers, and .rej through target %s\n' "${version}" } diff --git a/sdk/extensionrepo/cmd/extensionrepo/main.go b/sdk/extensionrepo/cmd/extensionrepo/main.go index bfeb800f..56113d04 100644 --- a/sdk/extensionrepo/cmd/extensionrepo/main.go +++ b/sdk/extensionrepo/cmd/extensionrepo/main.go @@ -1,4 +1,4 @@ -// Command extensionrepo runs repository tasks for core through the shared SDK. +// Command extensionrepo runs repository tasks through the shared SDK, including from a downloaded Nicely module. package main import ( diff --git a/sdk/extensionrepo/install.go b/sdk/extensionrepo/install.go index 489f4227..f19dca27 100644 --- a/sdk/extensionrepo/install.go +++ b/sdk/extensionrepo/install.go @@ -1,5 +1,5 @@ // Package extensionrepo runs repository tasks through the extension's export program. -// It owns staged install and uninstall, as Extension repository in contract.md defines. +// It owns checks, contract lookup, updates, staged install, and uninstall, as Extension repository in contract.md defines. // Its test wrapper prints notices after Go's package results, as Test kit defines. package extensionrepo diff --git a/sdk/extensionrepo/program.go b/sdk/extensionrepo/program.go index d08baba4..02b408de 100644 --- a/sdk/extensionrepo/program.go +++ b/sdk/extensionrepo/program.go @@ -12,7 +12,7 @@ import ( "github.com/pascalandy/nicely/sdk/extension" ) -// Main runs install, uninstall, test, or signoff from the repository, then exits. +// Main runs a repository task, then exits. // The export subcommand writes embedded sidecars for the staged build. func Main(e extension.Extension) { os.Exit(run(e, os.Args[1:])) @@ -25,11 +25,20 @@ func run(e extension.Extension, args []string) int { if len(args) == 1 && args[0] == "signoff" { return report("signoff", signoff()) } + if len(args) > 0 && args[0] == "update" { + return update(args[1:]) + } + if len(args) == 1 && args[0] == "check" { + return report("check", checkRepository()) + } + if len(args) == 1 && args[0] == "contract" { + return report("contract", printContractDirectory()) + } if len(args) == 2 && args[0] == "export" { return report("export", extension.Export(e, args[1])) } if len(args) != 1 || (args[0] != "install" && args[0] != "uninstall") { - fmt.Fprintln(os.Stderr, "usage: export install | uninstall | signoff | export | test [go test arguments]") + fmt.Fprintln(os.Stderr, "usage: export install | uninstall | signoff | check | contract | export | test [go test arguments] | update vX.Y.Z /absolute/repository [--continue]") return 2 } action := args[0] diff --git a/sdk/extensionrepo/recipes.go b/sdk/extensionrepo/recipes.go new file mode 100644 index 00000000..7f234043 --- /dev/null +++ b/sdk/extensionrepo/recipes.go @@ -0,0 +1,48 @@ +package extensionrepo + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func checkRepository() error { + if err := updateRun("go", "mod", "tidy", "-diff"); err != nil { + return err + } + files, err := exec.Command("gofmt", "-l", ".").Output() + if err != nil { + return err + } + if len(files) != 0 { + fmt.Fprint(os.Stderr, string(files)) + return errors.New("run gofmt -w . to format these files") + } + if err := updateRun("go", "vet", "./..."); err != nil { + return err + } + return updateRun("go", "test", "-count=1", "./...") +} + +func printContractDirectory() error { + if err := updateRun("go", "mod", "download", nicelyModule); err != nil { + return err + } + dir, err := updateOutput("go", "list", "-m", "-f", "{{.Dir}}", nicelyModule) + if err != nil { + return err + } + path := filepath.Join(strings.TrimSpace(string(dir)), "docs", "north-star", "contract") + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + fmt.Println(path) + return nil +} diff --git a/sdk/extensionrepo/update.go b/sdk/extensionrepo/update.go new file mode 100644 index 00000000..1d37da81 --- /dev/null +++ b/sdk/extensionrepo/update.go @@ -0,0 +1,254 @@ +package extensionrepo + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + + "go.yaml.in/yaml/v3" +) + +const nicelyModule = "github.com/pascalandy/nicely" + +func update(args []string) int { + if len(args) < 2 || len(args) > 3 || !regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`).MatchString(args[0]) || + !filepath.IsAbs(args[1]) || (len(args) == 3 && args[2] != "--continue") { + fmt.Fprintln(os.Stderr, "usage: update vX.Y.Z /absolute/repository [--continue]") + return 2 + } + if err := os.Chdir(args[1]); err != nil { + return report("update", err) + } + return report("update", updateRepository(args[0], args[1], len(args) == 3)) +} + +func updateRepository(version, repo string, resume bool) error { + if !resume { + head, err := updateOutput("git", "rev-parse", "--verify", "HEAD") + if err != nil { + return errors.New("commit the repository before updating") + } + status, err := updateOutput("git", "status", "--porcelain") + if err != nil { + return err + } + if len(status) != 0 { + return fmt.Errorf("the update needs a clean Git tree. Commit your changes, then run just update %s again", version) + } + if err := updateRun("uv", "tool", "run", "copier@9.19.1", "update", "--defaults", "--conflict", "inline", "--vcs-ref", version); err != nil { + fmt.Fprintln(os.Stderr, "update: Copier stopped. Files can be incomplete without a conflict marker.") + fmt.Fprintln(os.Stderr, "Return to the previous commit and inspect the added files before removing them:") + fmt.Fprintf(os.Stderr, " git restore --source=%s --staged --worktree -- .\n", strings.TrimSpace(string(head))) + fmt.Fprintln(os.Stderr, " git clean -d --dry-run") + fmt.Fprintln(os.Stderr, " git clean -d --force") + fmt.Fprintln(os.Stderr, "Then retry:") + fmt.Fprintln(os.Stderr, " "+updateBootstrap(version, repo, false)) + return err + } + } else { + data, err := os.ReadFile(".copier-answers.yml") + if err != nil { + return err + } + var answers struct { + Commit string `yaml:"_commit"` + } + if err := yaml.Unmarshal(data, &answers); err != nil { + return err + } + if answers.Commit != version { + return fmt.Errorf("continuation needs _commit: %s in .copier-answers.yml", version) + } + } + conflicts, err := updateConflicts() + if err != nil { + return err + } + if len(conflicts) != 0 { + fmt.Fprintln(os.Stderr, "update: the template conflicts with your changes in these files:") + for _, path := range conflicts { + fmt.Fprintln(os.Stderr, " "+path) + } + fmt.Fprintln(os.Stderr, "Resolve each conflict, mark its file with git add, and remove each .rej file.") + fmt.Fprintln(os.Stderr, "Then finish the update:") + fmt.Fprintln(os.Stderr, " "+updateBootstrap(version, repo, true)) + return errors.New("resolve the template conflicts before updating dependencies") + } + previous, err := updateOutput("go", "list", "-m", "-json", nicelyModule) + if err != nil { + return err + } + var old struct { + Version string + GoMod string + } + if err := json.Unmarshal(previous, &old); err != nil { + return err + } + if old.GoMod == "" { + old.GoMod, err = updateModuleFile(old.Version) + if err != nil { + return err + } + } + target, err := updateModuleFile(version) + if err != nil { + return err + } + edits, err := updateReplacements(old.GoMod, target) + if err != nil { + return err + } + if err := updateRun("go", "get", nicelyModule+"@"+version); err != nil { + return err + } + if len(edits) != 0 { + if err := updateRun("go", append([]string{"mod", "edit"}, edits...)...); err != nil { + return err + } + } + if err := updateRun("go", "mod", "tidy"); err != nil { + return err + } + return updateRun("just", "check") +} + +func updateModuleFile(version string) (string, error) { + data, err := updateOutput("go", "mod", "download", "-json", nicelyModule+"@"+version) + if err != nil { + return "", err + } + var module struct{ GoMod string } + if err := json.Unmarshal(data, &module); err != nil { + return "", err + } + if module.GoMod == "" { + return "", errors.New("the downloaded Nicely module has no go.mod") + } + return module.GoMod, nil +} + +func updateReplacements(previous, target string) ([]string, error) { + type module struct{ Path, Version string } + type replacement struct{ Old, New module } + key := func(m module) string { + if m.Version != "" { + return m.Path + "@" + m.Version + } + return m.Path + } + edits := make(map[string]string) + for i, path := range []string{previous, target} { + data, err := updateOutput("go", "mod", "edit", "-json", path) + if err != nil { + return nil, err + } + var file struct{ Replace []replacement } + if err := json.Unmarshal(data, &file); err != nil { + return nil, err + } + for _, r := range file.Replace { + old := key(r.Old) + edits[old] = "-dropreplace=" + old + if i == 1 { + edits[old] = "-replace=" + old + "=" + key(r.New) + } + } + } + var result []string + for _, edit := range edits { + result = append(result, edit) + } + slices.Sort(result) + return result, nil +} + +func updateConflicts() ([]string, error) { + unmerged, err := updateOutput("git", "diff", "--name-only", "--diff-filter=U", "-z") + if err != nil { + return nil, err + } + paths := make(map[string]bool) + for _, path := range strings.Split(string(unmerged), "\x00") { + if path != "" { + paths[path] = true + } + } + if err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && entry.Name() == ".git" { + return filepath.SkipDir + } + if !entry.IsDir() && strings.HasSuffix(path, ".rej") { + paths[path] = true + } + return nil + }); err != nil { + return nil, err + } + marker := regexp.MustCompile(`(?m)^(<<<<<<<|>>>>>>>)( |$)`) + for _, args := range [][]string{{"diff", "--name-only", "-z", "HEAD"}, {"ls-files", "--others", "--exclude-standard", "-z"}} { + changed, err := updateOutput("git", args...) + if err != nil { + return nil, err + } + for _, path := range strings.Split(string(changed), "\x00") { + if path == "" { + continue + } + info, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + continue + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if marker.Match(data) { + paths[path] = true + } + } + } + var result []string + for path := range paths { + result = append(result, path) + } + slices.Sort(result) + return result, nil +} + +func updateBootstrap(version, repo string, resume bool) string { + command := `go -C "$(go mod download -json ` + nicelyModule + `@` + version + ` | sed -n 's/^[[:space:]]*"Dir": "\(.*\)",$/\1/p')" run ./sdk/extensionrepo/cmd/extensionrepo update ` + version + " '" + strings.ReplaceAll(repo, "'", "'\\''") + "'" + if resume { + command += " --continue" + } + return command +} + +func updateRun(name string, args ...string) error { + fmt.Fprintln(os.Stderr, strings.Join(append([]string{name}, args...), " ")) + cmd := exec.Command(name, args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() +} + +func updateOutput(name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + return cmd.Output() +} diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index 906ff774..032b3864 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -52,9 +52,24 @@ After Copier creates the repository, run `go mod tidy` once, which writes `go.su - `just install`: installs the extension into Nicely's extensions folder, in place of its previous install - `just uninstall`: removes that install, and keeps its keys and grants - `just acceptance [fixture]`: runs `acceptance.sh`, which runs the command of `## Acceptance` as an agent would, in scratch folders. It works on a copy of the fixture, or on an input that it makes -- `just update `: moves the repository to another Nicely version, the template first and then the SDK, on a clean Git tree. It keeps `go.mod` with your dependencies, and moves its Nicely pin with `go get` +- `just update `: runs the target version's updater on a clean Git tree, with the template before the SDK - `just contract`: prints the folder of the contract at the Nicely version that `go.mod` pins +### Update an older repository + +For a repository whose update recipe holds a script, run the target updater directly. +Replace VERSION with the target Nicely tag, such as `v0.24.0`: + +```sh +go -C "$(go mod download -json github.com/pascalandy/nicely@VERSION | sed -n 's/^[[:space:]]*"Dir": "\(.*\)",$/\1/p')" run ./sdk/extensionrepo/cmd/extensionrepo update VERSION "$PWD" +``` + +The updater keeps your dependencies and reconciles Nicely's replacements in `go.mod`. +On a Copier conflict, resolve each file and mark it with `git add`. +Remove each `.rej` file, then run the printed bootstrap command with `--continue`. +This finishes dependency updates and runs `just check`. +"Update an extension" in `extension-repository.md` of the contract holds the recovery rules. + ## Done when `just check` passes, and `just acceptance` passes on the real input of `## Acceptance`. For a paid command, its dry run is enough. For a command that needs a key, relay the grant hint to the human and wait. diff --git a/template/justfile b/template/justfile index 7763ff21..9304e225 100644 --- a/template/justfile +++ b/template/justfile @@ -1,98 +1,36 @@ set shell := ["bash", "-euo", "pipefail", "-c"] -# The identity of the extension, which manifest.toml declares -domain := `sed -n 's/^domain = "\([a-z][a-z0-9]*\)"$/\1/p' manifest.toml` -name := `sed -n '/^\[/q; s/^name = "\([a-z][a-z0-9]*\)"$/\1/p' manifest.toml` -slug := domain + if name == "" { "" } else { "-" + name } - -# The Copier of just update, pinned and run through uv. No template task runs, so it needs no --trust +# The Copier used to create the repository, also pinned by the target SDK updater copier := "uv tool run copier@9.19.1" # List the recipes default: @just --list -# Run every check: go mod tidy -diff, gofmt, go vet, and go test, whose test kit installs the extension into scratch folders +# Run every repository check through the pinned SDK check: - go mod tidy -diff - @test -z "$(gofmt -l .)" || { gofmt -l . >&2; echo 'check: format these files with gofmt -w .' >&2; exit 1; } - go vet ./... - go run ./internal/export test + go run github.com/pascalandy/nicely/sdk/extensionrepo/cmd/extensionrepo check # Check the committed SHA in a detached scratch worktree, then sign that exact SHA signoff: go run ./internal/export signoff -# Build ncly- and install it with its sidecars into Nicely's extensions folder, in place of its previous install +# Build the extension and install its sidecars before its executable install: go run ./internal/export install -# Remove the install of ncly- and its sidecars from Nicely's extensions folder, the executable first +# Remove the installed extension, with its executable first uninstall: go run ./internal/export uninstall -# Run the command of ## Acceptance in spec.md as an agent would, through acceptance.sh, on a copy of the fixture or on a made input +# Run the command of ## Acceptance on a copy of the fixture or on a made input acceptance fixture="": @bash acceptance.sh {{ if fixture == "" { "" } else { quote(join(invocation_directory(), fixture)) } }} -# Move the repository to another Nicely version, such as v0.19.0: the template first, then the SDK, on a clean Git tree +# Run the target version's updater, with the template before the SDK, on a clean Git tree update version: - #!/usr/bin/env bash - set -Eeuo pipefail - version={{ quote(version) }} - if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Usage: just update vX.Y.Z, such as just update v0.19.0" >&2 - exit 2 - fi - if ! head="$(git rev-parse --verify --quiet HEAD)" || [[ -n "$(git status --porcelain)" ]]; then - echo "update: the update needs a clean Git tree. Commit your changes, then run just update $version again" >&2 - git status --short >&2 || true - exit 1 - fi - # The recipe never resets, stashes, or commits: the author decides what to keep. - if ! {{ copier }} update --defaults --conflict inline --vcs-ref "$version"; then - echo "update: Copier stopped, so files can be incomplete without a conflict marker." >&2 - echo "Return to the commit before the update, remove the files that it added, and run it again:" >&2 - echo " git restore --source=$head --staged --worktree -- ." >&2 - echo " git clean -d --dry-run" >&2 - echo " git clean -d --force" >&2 - echo " just update $version" >&2 - exit 1 - fi - # Copier writes a conflict as markers, or as a .rej file, and can still exit 0. It marks a - # conflicted file as unmerged in the index. Look for all three before go.mod changes. - conflicts="$( - git ls-files --unmerged | cut -f2 - find . -name '*.rej' -not -path './.git/*' | sed 's|^\./||' - { git diff --name-only HEAD; git ls-files --others --exclude-standard; } | while IFS= read -r file; do - if [[ -f "$file" ]] && grep -qE '^(<<<<<<<|>>>>>>>)( |$)' "$file"; then echo "$file"; fi - done - )" - if [[ -n "$conflicts" ]]; then - echo "update: the template conflicts with your changes in these files:" >&2 - printf '%s\n' "$conflicts" | sort -u | sed 's/^/ /' >&2 - echo "Resolve each conflict, mark its file with git add, and delete each .rej file." >&2 - echo "Then finish the update with these three commands:" >&2 - echo " go get github.com/pascalandy/nicely@$version" >&2 - echo " go mod tidy" >&2 - echo " just check" >&2 - exit 1 - fi - # Copier leaves go.mod and the dependencies of the author, so go get moves its Nicely pin. - # A replace never reaches another module. go.mod takes each line replace X => Y V of Nicely's go.mod, and drops each one that Nicely dropped. - previous="$(go list -m -f '{{{{.GoMod}}' github.com/pascalandy/nicely)" - go get "github.com/pascalandy/nicely@$version" - edits="$(awk 'FNR == 1 { file++ } $1 == "replace" && $3 == "=>" && NF == 5 { if (file == 1) dropped[$2] = 1; else { delete dropped[$2]; print "-replace=" $2 "=" $4 "@" $5 } } END { for (path in dropped) print "-dropreplace=" path }' "$previous" "$(go list -m -f '{{{{.GoMod}}' github.com/pascalandy/nicely)")" - if [[ -n "$edits" ]]; then go mod edit $edits; fi - go mod tidy - just check + go -C "$(go mod download -json github.com/pascalandy/nicely@{{ quote(version) }} | sed -n 's/^[[:space:]]*"Dir": "\(.*\)",$/\1/p')" run ./sdk/extensionrepo/cmd/extensionrepo update {{ quote(version) }} {{ quote(justfile_directory()) }} -# Print the folder of the contract at the Nicely version that go.mod pins, to read its rules offline +# Print the folder of the pinned contract contract: - #!/usr/bin/env bash - set -Eeuo pipefail - # go list gives the folder of a module only once the module cache holds it. - go mod download github.com/pascalandy/nicely - dir="$(go list -m -f '{{{{.Dir}}' github.com/pascalandy/nicely)/docs/north-star/contract" - [[ -d "$dir" ]] || { echo "contract: $dir is missing from the Nicely version that go.mod pins" >&2; exit 1; } - echo "$dir" + go run github.com/pascalandy/nicely/sdk/extensionrepo/cmd/extensionrepo contract From e35bd0bbd27a06c7aa8b9a34b90b5c8f07f41009 Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Fri, 30 Oct 2026 00:22:03 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9A=91=20fix:=20sdk:=20preserve=20tes?= =?UTF-8?q?t=20notices=20in=20repository=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: Passing Go tests can hide skipped-example notices - Impact: Run checks through Test and preserve its exit code --- docs/north-star/contract/manifest-examples.md | 2 +- sdk/extensionrepo/program.go | 2 +- sdk/extensionrepo/recipes.go | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/north-star/contract/manifest-examples.md b/docs/north-star/contract/manifest-examples.md index 6423f3cd..ab6bb463 100644 --- a/docs/north-star/contract/manifest-examples.md +++ b/docs/north-star/contract/manifest-examples.md @@ -5,7 +5,7 @@ It adds `--dry-run` when the example's command supports that mode, including exa Without that mode, it runs only commands whose `effects` explicitly declare `paid = false`. A missing effects declaration permits no assumption. Each skipped example prints its reason, including commands outside the extension's manifest. -The template runs tests through `go run ./internal/export test`, which keeps package list output without `-v`. +The template's `just check` runs tests through `extensionrepo.Test`, which keeps package list output without `-v`. The SDK wrapper prints collected notices to stderr after the tests, including when tests fail. It preserves their exit code and keeps stdout valid for `go test -json`. Core keeps Go's result cache and uses the rendered template sample to check notices. diff --git a/sdk/extensionrepo/program.go b/sdk/extensionrepo/program.go index 02b408de..726f7a1d 100644 --- a/sdk/extensionrepo/program.go +++ b/sdk/extensionrepo/program.go @@ -29,7 +29,7 @@ func run(e extension.Extension, args []string) int { return update(args[1:]) } if len(args) == 1 && args[0] == "check" { - return report("check", checkRepository()) + return checkRepository() } if len(args) == 1 && args[0] == "contract" { return report("contract", printContractDirectory()) diff --git a/sdk/extensionrepo/recipes.go b/sdk/extensionrepo/recipes.go index 7f234043..03c681a8 100644 --- a/sdk/extensionrepo/recipes.go +++ b/sdk/extensionrepo/recipes.go @@ -9,22 +9,23 @@ import ( "strings" ) -func checkRepository() error { +func checkRepository() int { if err := updateRun("go", "mod", "tidy", "-diff"); err != nil { - return err + return report("check", err) } files, err := exec.Command("gofmt", "-l", ".").Output() if err != nil { - return err + return report("check", err) } if len(files) != 0 { fmt.Fprint(os.Stderr, string(files)) - return errors.New("run gofmt -w . to format these files") + return report("check", errors.New("run gofmt -w . to format these files")) } if err := updateRun("go", "vet", "./..."); err != nil { - return err + return report("check", err) } - return updateRun("go", "test", "-count=1", "./...") + fmt.Fprintln(os.Stderr, "go test -count=1 ./...") + return Test(nil) } func printContractDirectory() error {