From a01f8137ab0a4c5821ac9603a7306917dac50d1d Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Thu, 29 Oct 2026 23:12:18 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=9A=91=20fix:=20sdk:=20sign=20off=20o?= =?UTF-8?q?nly=20the=20isolated=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Purpose: prevent dirty fixes and moving HEAD from clearing an unfixed commit Impact: share detached signoff and receipts between core and the template --- docs/backlog/COR-103-verification-loop.md | 2 +- .../contract/extension-repository.md | 6 +- docs/north-star/decisions/D016-local-ci.md | 18 ++ scripts/signoff-test.sh | 190 +++++++++++++----- scripts/signoff.sh | 13 +- scripts/template-recipes-test.sh | 27 ++- sdk/extensionrepo/cmd/extensionrepo/main.go | 11 + sdk/extensionrepo/program.go | 7 +- sdk/extensionrepo/signoff.go | 159 +++++++++++++++ sdk/extensionrepo/signoff_receipt.go | 61 ++++++ template/AGENTS.md.jinja | 15 +- template/justfile | 4 + 12 files changed, 439 insertions(+), 74 deletions(-) create mode 100644 sdk/extensionrepo/cmd/extensionrepo/main.go create mode 100644 sdk/extensionrepo/signoff.go create mode 100644 sdk/extensionrepo/signoff_receipt.go diff --git a/docs/backlog/COR-103-verification-loop.md b/docs/backlog/COR-103-verification-loop.md index 208104c6..a12a076d 100644 --- a/docs/backlog/COR-103-verification-loop.md +++ b/docs/backlog/COR-103-verification-loop.md @@ -31,7 +31,7 @@ Each check fails first on one named past mistake. | 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-T9 | Sign off an isolated checkout | agent | COR-103-T7 | todo | +| 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 | | COR-103-T12 | Author path from the published tag | agent | - | done | diff --git a/docs/north-star/contract/extension-repository.md b/docs/north-star/contract/extension-repository.md index 0cf45899..0eb2647d 100644 --- a/docs/north-star/contract/extension-repository.md +++ b/docs/north-star/contract/extension-repository.md @@ -31,9 +31,10 @@ The template refuses `main` and each Go keyword as a domain, such as `type`. It A push can skip Lefthook, so its GitHub workflow runs `just check` again on `ubuntu-24.04`. Public repositories also run the same steps on `macos-15`, and both runners are free. -The repository tests itself through the [test kit](test-kit.md). Its `justfile` has six recipes: +The repository tests itself through the [test kit](test-kit.md). Its `justfile` has seven recipes: - `just check` runs every check: `go mod tidy -diff`, `gofmt`, `go vet`, and `go test`, whose test kit installs the extension into scratch folders +- `just signoff` checks the committed SHA in isolation, then signs that SHA, as [Local checks](../decisions/D016-local-ci.md) defines - `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 @@ -42,9 +43,10 @@ The repository tests itself through the [test kit](test-kit.md). Its `justfile` `just install` grants no key. A human grants one after reviewing the extension, as [Grants](grants.md) defines. -The SDK package `extensionrepo` owns install and uninstall. +The SDK package `extensionrepo` owns install, uninstall, and signoff. The template's `internal/export/` program calls `extensionrepo.Main(Extension)`. The recipes each call that program in one line: `go run ./internal/export install` or `go run ./internal/export uninstall`. +Signoff calls `go run ./internal/export signoff`. Its `export ` subcommand writes the embedded sidecars of the staged build. Install stages both builds beside the destination with one `go build`. diff --git a/docs/north-star/decisions/D016-local-ci.md b/docs/north-star/decisions/D016-local-ci.md index 289fcc71..ca0c125a 100644 --- a/docs/north-star/decisions/D016-local-ci.md +++ b/docs/north-star/decisions/D016-local-ci.md @@ -4,4 +4,22 @@ Decided 2026-10-05. Changed 2026-10-29 by [#373](https://github.com/pascalandy/n **Why.** Local checks are faster, and `just signoff` proves the exact commit that reaches `main`. A push can skip the pre-push hook of Lefthook, so a second run on a clean runner shows a broken `main`. Nicely is public, so standard GitHub runners run that check for free. +`just signoff` uses `sdk/extensionrepo` in core and extension repositories. +The program freezes the full HEAD SHA and creates a detached scratch worktree at that SHA. +It clears `JUST_*` and `GIT_*` from its child environment before it resolves the repository or runs a check. +It runs that worktree's `just check` once, including its dependencies. +A failed check, a changed HEAD, or a changed tracked file prevents signoff. +Uncommitted changes in the caller cannot fix the checked commit. +After the checks pass, `gh signoff --commit ` signs only the frozen SHA. +Success prints `Signed off on ` with the full SHA. + +Each run prints the path of its JSON receipt under Nicely's cache folder, in `signoff/`. +That folder must be outside the repository. +The receipt records the SHA, host OS, tool versions, and each invoked gate's exit code. +The current gate is `just check`. +The receipt exists before signoff and then records the exit code of `gh signoff`. +The run also keeps its check log, including failures. +The scratch worktree goes when the program returns. +Every run repeats the checks, even when a receipt already exists. + **Rejected.** Local checks alone, because a skipped hook then leaves a broken `main` unseen, as #373 found. GitHub Actions as the only gate, because Pascal often hits its limits, and local checks are faster. A required `check` status, because `signoff` already gates `main`. diff --git a/scripts/signoff-test.sh b/scripts/signoff-test.sh index 66e295db..99fe57e5 100644 --- a/scripts/signoff-test.sh +++ b/scripts/signoff-test.sh @@ -1,93 +1,181 @@ #!/usr/bin/env bash -# signoff-test.sh proves just signoff for just test: it runs the check once, -# then gh signoff, even under JUST_NO_DEPS=true or --no-deps, and it signs -# nothing after a failed check. It runs on macOS and Linux, with bash 3.2. -# -# The caller's justfile is a copy of the real one, where only check is a -# stub. The stub writes CHECK-RAN from a dependency of check, because -# JUST_NO_DEPS and --no-deps skip exactly those. gh is a stub too, so no case -# reaches the network. See #213. +# Prove isolated signoff through core's recipe, with real Git and just. +# gh is always a stub. A dirty fix must not make a broken commit pass. set -Eeuo pipefail trap 'printf "%s: line %s failed: %s\n" "${BASH_SOURCE[0]##*/}" "${LINENO}" "${BASH_COMMAND}" >&2' ERR -SIGNOFF_TEST_ROOT="" +root="" test_case="setup" -# shellcheck disable=SC2317,SC2329 fct_cleanup() { - if [[ -n "${SIGNOFF_TEST_ROOT}" ]]; then - rm -rf "${SIGNOFF_TEST_ROOT}" - fi + # Keep artifacts when trash is absent, such as on a hosted runner. + if command -v trash >/dev/null; then trash "${root}"; fi } fct_fail() { + cat "${root}/output" >&2 printf 'signoff-test: %s: %s\n' "${test_case}" "$*" >&2 exit 1 } fct_setup() { - local repo_root + local repo_root variable + for variable in $(compgen -e); do + case "${variable}" in GIT_* | JUST_*) unset "${variable}" ;; esac + done repo_root="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd -P)" root="$(mktemp -d "${TMPDIR:-/tmp}/ncly-signoff-test.XXXXXXXX")" - SIGNOFF_TEST_ROOT="${root}" - mkdir -p "${root}/bin" "${root}/caller/scripts" - export NCLY_SIGNOFF_TEST="${root}" PATH="${root}/bin:${PATH}" + mkdir -p "${root}/bin" "${root}/caller/scripts" "${root}/cache" + export NCLY_SIGNOFF_TEST="${root}" PATH="${root}/bin:${PATH}" XDG_CACHE_HOME="${root}/cache" cat >"${root}/bin/gh" <<'SCRIPT' #!/usr/bin/env bash set -Eeuo pipefail -if [[ "$*" != signoff ]]; then - echo "gh stub: no answer for: $*" >&2 - exit 1 +case "$*" in +--version) + if git symbolic-ref --quiet HEAD >/dev/null; then + echo 'version probe is not isolated' >&2 + exit 1 + fi + echo 'gh version test' + exit 0 + ;; +signoff | signoff\ --commit\ *) ;; +*) echo "gh stub refuses: $*" >&2; exit 1 ;; +esac +if [[ "$*" == signoff\ --commit\ * ]]; then + jq -e --arg sha "${3}" '.sha == $sha and .checks[0].exit == 0 and .signoff == null' ../receipt.json >/dev/null fi -echo SIGNOFF-RAN >>"${NCLY_SIGNOFF_TEST}/log" +printf '%s\n' "$*" >>"${NCLY_SIGNOFF_TEST}/signed" +git rev-parse HEAD >>"${NCLY_SIGNOFF_TEST}/signed-head" +[[ ! -e "${NCLY_SIGNOFF_TEST}/gh-fails" ]] SCRIPT chmod 755 "${root}/bin/gh" cp "${repo_root}/justfile" "${root}/caller/justfile" + cp "${repo_root}/go.mod" "${repo_root}/go.sum" "${root}/caller/" + cp -R "${repo_root}/sdk" "${root}/caller/sdk" + cp "${repo_root}/scripts/signoff.sh" "${root}/caller/scripts/signoff.sh" cat >>"${root}/caller/justfile" <<'JUST' set allow-duplicate-recipes -# The stub check of scripts/signoff-test.sh replaces the real one, and only lists a dependency, as the real one does check: signoff-test-check signoff-test-check: - @echo CHECK-RAN >>"${NCLY_SIGNOFF_TEST}/log" - @test ! -e "${NCLY_SIGNOFF_TEST}/fail" + @git rev-parse HEAD >>"${NCLY_SIGNOFF_TEST}/checked" + @test -z "${GIT_DIR-}${GIT_WORK_TREE-}${GIT_SIGNOFF_TEST-}${JUST_SIGNOFF_TEST-}${JUST_NO_DEPS-}" + @test "$(cat fix.txt)" = fixed || { echo COMMITTED-FAILURE >&2; exit 1; } + @if [[ -e "${NCLY_SIGNOFF_TEST}/move-head" ]]; then git checkout --quiet --detach HEAD~; fi + @if [[ -e "${NCLY_SIGNOFF_TEST}/change-tracked" ]]; then echo changed >fix.txt; fi + @if [[ -e "${NCLY_SIGNOFF_TEST}/move-caller" ]]; then git -C "${NCLY_SIGNOFF_TEST}/caller" -c commit.gpgsign=false commit --quiet --allow-empty --message 'caller moved'; fi JUST - cp "${repo_root}/scripts/signoff.sh" "${root}/caller/scripts/signoff.sh" + git -C "${root}/caller" init -q + git -C "${root}/caller" config user.name 'Signoff test' + git -C "${root}/caller" config user.email 'test@example.com' + git -C "${root}/caller" config core.hooksPath /dev/null + printf 'broken\n' >"${root}/caller/fix.txt" + git -C "${root}/caller" add . + git -C "${root}/caller" commit -qm 'broken commit' + sha="$(git -C "${root}/caller" rev-parse HEAD)" + : >"${root}/output" + : >"${root}/signed" + : >"${root}/signed-head" } -# fct_expect ... runs the three forms of just signoff, and requires each to end as given and to write exactly these lines, in this order. -fct_expect() { - local outcome="${1}" form status +# Run core's recipe and check the observable result, including the checked SHA. +fct_run() { + local outcome="${1}" status=0 shift - printf '%s\n' "$@" >"${root}/log.expected" - for form in "just signoff" "env JUST_NO_DEPS=true just signoff" "just --no-deps signoff"; do - : >"${root}/log" - status=0 - # shellcheck disable=SC2086 - (cd "${root}/caller" && ${form}) >"${root}/output" 2>&1 &2 - fct_fail "${form} exited ${status}" - fi - if ! cmp -s "${root}/log.expected" "${root}/log"; then - diff "${root}/log.expected" "${root}/log" >&2 || true - fct_fail "${form} ran other steps" - fi - done + : >"${root}/checked" + : >"${root}/signed" + : >"${root}/signed-head" + (cd "${root}/caller" && "$@") >"${root}/output" 2>&1 "${root}/fail" - fct_expect fails CHECK-RAN - printf 'just signoff runs the check once before gh signoff, under JUST_NO_DEPS and --no-deps too, and signs nothing after a failed check\n' + trap fct_cleanup EXIT + test_case="dirty fix" + printf 'fixed\n' >"${root}/caller/fix.txt" + (cd "${root}/caller" && just check) >"${root}/output" 2>&1 + fct_run fails just signoff + grep -qF COMMITTED-FAILURE "${root}/output" || fct_fail 'the committed defect did not fail the check' + [[ ! -s "${root}/signed" ]] || fct_fail 'signed the unfixed commit' + fct_receipt 1 null + + git -C "${root}/caller" add fix.txt + git -C "${root}/caller" commit -qm 'committed fix' + sha="$(git -C "${root}/caller" rev-parse HEAD)" + printf 'dirty caller\n' >"${root}/caller/fix.txt" + printf 'untracked caller\n' >"${root}/caller/untracked.txt" + git -C "${root}/caller" diff >"${root}/before" + local form + mkdir "${root}/decoy" + git -C "${root}/decoy" init -q + printf 'check:\n @exit 1\n' >"${root}/decoy/justfile" + for form in normal no-deps-env no-deps-flag caller-env; do + test_case="passing commit, ${form}" + case "${form}" in + normal) fct_run passes just signoff ;; + no-deps-env) fct_run passes env JUST_NO_DEPS=true just signoff ;; + no-deps-flag) fct_run passes just --no-deps signoff ;; + caller-env) + fct_run passes env GIT_DIR="${root}/decoy/.git" GIT_WORK_TREE="${root}/decoy" GIT_SIGNOFF_TEST=leak \ + JUST_SIGNOFF_TEST=leak JUST_NO_DEPS=true JUST_JUSTFILE="${root}/decoy/justfile" JUST_WORKING_DIRECTORY="${root}/decoy" \ + just --justfile "${root}/caller/justfile" --working-directory "${root}/caller" signoff + ;; + esac + [[ "$(cat "${root}/signed")" == "signoff --commit ${sha}" ]] || fct_fail 'gh did not receive the frozen full SHA' + [[ "$(cat "${root}/signed-head")" == "${sha}" ]] || fct_fail 'gh ran outside the frozen checkout' + grep -qxF "Signed off on ${sha}" "${root}/output" || fct_fail 'missing landing-script output' + fct_receipt 0 0 + done + git -C "${root}/caller" diff >"${root}/after" + cmp -s "${root}/before" "${root}/after" || fct_fail 'signoff changed the caller' + [[ "$(cat "${root}/caller/untracked.txt")" == 'untracked caller' ]] || fct_fail 'signoff removed caller files' + [[ "$(git -C "${root}/caller" worktree list --porcelain | grep -c '^worktree ')" == 1 ]] || fct_fail 'scratch worktree remains' + + test_case="caller HEAD moves during the check" + : >"${root}/move-caller" + fct_run passes just signoff + [[ "$(cat "${root}/signed")" == "signoff --commit ${sha}" ]] || fct_fail 'signed the moved caller HEAD' + fct_receipt 0 0 + [[ "$(git -C "${root}/caller" rev-parse HEAD)" != "${sha}" ]] || fct_fail 'caller HEAD did not move' + sha="$(git -C "${root}/caller" rev-parse HEAD)" + mv "${root}/move-caller" "${root}/caller-moved" + + test_case="gh failure" + : >"${root}/gh-fails" + fct_run fails just signoff + if grep -q '^Signed off on ' "${root}/output"; then fct_fail 'reported success after gh failed'; fi + fct_receipt 0 1 + mv "${root}/gh-fails" "${root}/gh-failed" + for form in move-head change-tracked; do + test_case="${form} during the check" + : >"${root}/${form}" + fct_run fails just signoff + [[ ! -s "${root}/signed" ]] || fct_fail 'signed a changed checkout' + grep -q 'during the check' "${root}/output" || fct_fail 'failed before the checkout guard' + mv "${root}/${form}" "${root}/${form}-done" + done + printf 'signoff: dirty fixes fail, frozen SHAs pass, caller files stay, and failed gates sign nothing\n' +} + +fct_receipt() { + local receipt + receipt="$(sed -n 's/^signoff: receipt in //p' "${root}/output")" + [[ "${receipt}" == "${root}/cache/nicely/signoff/"*/receipt.json ]] || fct_fail 'receipt is not outside the repository' + jq -e --arg sha "${sha}" --argjson check "${1}" --argjson signoff "${2}" ' + .sha == $sha and (.os == "linux" or .os == "darwin") and + ([.tools.git, .tools.go, .tools.just, .tools.gh] | all(type == "string" and length > 0)) and + (.checks | length == 1) and .checks[0].command[0] == "just" and .checks[0].exit == $check and + (if $signoff == null then .signoff == null + else .signoff.command == ["gh", "signoff", "--commit", $sha] and .signoff.exit == $signoff end) + ' "${receipt}" >/dev/null || fct_fail 'receipt does not describe the actual run' + [[ -s "${receipt%/*}/check.log" ]] || fct_fail 'receipt has no check log' } fct_main -exit 0 diff --git a/scripts/signoff.sh b/scripts/signoff.sh index d921e7f7..ac30fde5 100644 --- a/scripts/signoff.sh +++ b/scripts/signoff.sh @@ -1,15 +1,6 @@ #!/usr/bin/env bash -# signoff.sh runs just check, then marks the pushed HEAD green with -# gh signoff, for just signoff. The check runs here, not as a dependency of -# the recipe, because JUST_NO_DEPS and --no-deps skip dependencies. It runs on -# macOS and Linux. scripts/signoff-test.sh proves it. See #213. +# Run the shared isolated-checkout signoff program. scripts/signoff-test.sh proves it. set -Eeuo pipefail -# A caller can export JUST_NO_DEPS or JUST_JUSTFILE, which would run nothing, or the check of another justfile. -for just_variable in $(compgen -e); do - if [[ "$just_variable" == JUST_* ]]; then unset "$just_variable"; fi -done - root="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd -P)" cd "$root" -just --justfile "$root/justfile" --working-directory "$root" check -gh signoff +exec go run ./sdk/extensionrepo/cmd/extensionrepo signoff diff --git a/scripts/template-recipes-test.sh b/scripts/template-recipes-test.sh index 02195da5..e6de7369 100644 --- a/scripts/template-recipes-test.sh +++ b/scripts/template-recipes-test.sh @@ -10,7 +10,7 @@ fct_test_files() { fct_has "${repo}/.github/workflows/check.yml" " os: \${{ github.event.repository.private && fromJSON('[\"ubuntu-24.04\"]') || fromJSON('[\"ubuntu-24.04\",\"macos-15\"]') }}" fct_has "${repo}/.github/workflows/check.yml" " runs-on: \${{ matrix.os }}" fct_has "${repo}/qa.md" "# ${slug} QA" - for recipe in install uninstall; do + for recipe in install uninstall signoff; do [[ "$(just --justfile "${repo}/justfile" --show "${recipe}" | sed '/^#/d')" == "${recipe}:"$'\n'" go run ./internal/export ${recipe}" ]] || fct_fail "just ${recipe} must call the SDK program in one line" done @@ -82,6 +82,31 @@ fct_test_uninstall() { (cd "${repo}" && just uninstall) >"${scratch}/uninstall.out" fct_has "${scratch}/uninstall.out" "uninstall: nothing to remove, since ${extensions} holds no ncly-${slug}" fct_test_install_order "${repo}" "${slug}" "${extensions}" "${scratch}" + fct_test_signoff "${repo}" "${scratch}" +} + +# Run the rendered recipe with real Git and just. Only gh is a stub. +fct_test_signoff() { + local repo="${1}" scratch="${2}" sha + sha="$(git -C "${repo}" rev-parse HEAD)" + mkdir -p "${scratch}/signoff-bin" + cat >"${scratch}/signoff-bin/gh" <<'SCRIPT' +#!/usr/bin/env bash +set -Eeuo pipefail +if [[ "$*" == --version ]]; then echo 'gh version test'; exit 0; fi +[[ "$#" == 3 && "$1" == signoff && "$2" == --commit ]] +[[ "$(git rev-parse HEAD)" == "$3" ]] +if git symbolic-ref --quiet HEAD; then exit 1; fi +printf '%s\n' "$3" >"${NCLY_TEMPLATE_SIGNOFF}" +SCRIPT + chmod 755 "${scratch}/signoff-bin/gh" + if ! (cd "${repo}" && PATH="${scratch}/signoff-bin:${PATH}" XDG_CACHE_HOME="${scratch}/signoff-cache" \ + NCLY_TEMPLATE_SIGNOFF="${scratch}/signed-sha" just signoff) >"${scratch}/signoff.out" 2>&1; then + cat "${scratch}/signoff.out" >&2 + fct_fail 'rendered just signoff failed' + fi + fct_has "${scratch}/signoff.out" "Signed off on ${sha}" + fct_has "${scratch}/signed-sha" "${sha}" } # fct_test_install_order blocks manifest publication in $3, then tries just install in $1. diff --git a/sdk/extensionrepo/cmd/extensionrepo/main.go b/sdk/extensionrepo/cmd/extensionrepo/main.go new file mode 100644 index 00000000..bfeb800f --- /dev/null +++ b/sdk/extensionrepo/cmd/extensionrepo/main.go @@ -0,0 +1,11 @@ +// Command extensionrepo runs repository tasks for core through the shared SDK. +package main + +import ( + "github.com/pascalandy/nicely/sdk/extension" + "github.com/pascalandy/nicely/sdk/extensionrepo" +) + +func main() { + extensionrepo.Main(extension.Extension{}) +} diff --git a/sdk/extensionrepo/program.go b/sdk/extensionrepo/program.go index 8c9638ed..d08baba4 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, or test from the repository, then exits. +// Main runs install, uninstall, test, or signoff from the repository, then exits. // The export subcommand writes embedded sidecars for the staged build. func Main(e extension.Extension) { os.Exit(run(e, os.Args[1:])) @@ -22,11 +22,14 @@ func run(e extension.Extension, args []string) int { if len(args) > 0 && args[0] == "test" { return Test(args[1:]) } + if len(args) == 1 && args[0] == "signoff" { + return report("signoff", signoff()) + } 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 | export | test [go test arguments]") + fmt.Fprintln(os.Stderr, "usage: export install | uninstall | signoff | export | test [go test arguments]") return 2 } action := args[0] diff --git a/sdk/extensionrepo/signoff.go b/sdk/extensionrepo/signoff.go new file mode 100644 index 00000000..45cbe9cd --- /dev/null +++ b/sdk/extensionrepo/signoff.go @@ -0,0 +1,159 @@ +package extensionrepo + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" +) + +// signoff checks one frozen commit, without the caller's uncommitted files or task environment. +func signoff() error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + env := signoffEnv() + root, err := signoffOutput(ctx, env, "", "git", "rev-parse", "--show-toplevel") + if err != nil { + return err + } + sha, err := signoffOutput(ctx, env, root, "git", "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return err + } + run, err := newSignoffRun(root, sha) + if err != nil { + return err + } + fmt.Fprintln(os.Stderr, "signoff: receipt in", filepath.Join(run, "receipt.json")) + receipt := signoffReceipt{SHA: sha, OS: runtime.GOOS, Tools: map[string]string{}, Checks: []signoffCheck{}} + if err := receipt.write(run); err != nil { + return err + } + worktree := filepath.Join(run, "worktree") + // Cleanup uses a fresh context because cancellation must still remove the worktree. + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := signoffCommand(cleanupCtx, env, root, "git", "worktree", "remove", "--force", worktree) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "signoff: worktree cleanup: %v: %s\n", err, output) + } + }() + if _, err := signoffOutput(ctx, env, root, "git", "worktree", "add", "--quiet", "--detach", worktree, sha); err != nil { + return err + } + for _, tool := range [][]string{{"git", "--version"}, {"go", "version"}, {"just", "--version"}, {"gh", "--version"}} { + version, err := signoffOutput(ctx, env, worktree, tool[0], tool[1:]...) + if err != nil { + return err + } + receipt.Tools[tool[0]] = version + } + if err := receipt.write(run); err != nil { + return err + } + log, err := os.OpenFile(filepath.Join(run, "check.log"), os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { _ = log.Close() }() + if _, err := fmt.Fprintf(log, "SHA %s\n$ just check\n", sha); err != nil { + return err + } + check := []string{"just", "--justfile", filepath.Join(worktree, "justfile"), "--working-directory", worktree, "check"} + cmd := signoffCommand(ctx, env, worktree, check[0], check[1:]...) + cmd.Stdout, cmd.Stderr = io.MultiWriter(os.Stdout, log), io.MultiWriter(os.Stderr, log) + err = cmd.Run() + receipt.Checks = append(receipt.Checks, signoffCheck{Command: check, Exit: signoffExit(err)}) + if writeErr := receipt.write(run); writeErr != nil { + return writeErr + } + if err != nil { + return fmt.Errorf("check of %s failed: %w", sha, err) + } + if err := signoffUnchanged(ctx, env, worktree, sha); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + args := []string{"gh", "signoff", "--commit", sha} + cmd = signoffCommand(ctx, env, worktree, args[0], args[1:]...) + // Only this program prints the landing receipt line, after gh succeeds. + cmd.Stdout, cmd.Stderr = log, io.MultiWriter(os.Stderr, log) + err = cmd.Run() + receipt.Signoff = &signoffCheck{Command: args, Exit: signoffExit(err)} + if writeErr := receipt.write(run); writeErr != nil { + return writeErr + } + if err != nil { + return fmt.Errorf("gh signoff of %s failed: %w", sha, err) + } + fmt.Println("Signed off on", sha) + return nil +} + +func signoffUnchanged(ctx context.Context, env []string, worktree, sha string) error { + head, err := signoffOutput(ctx, env, worktree, "git", "rev-parse", "HEAD") + if err != nil { + return err + } + if head != sha { + return errors.New("HEAD moved during the check; commit the intended files and repeat just signoff") + } + status, err := signoffOutput(ctx, env, worktree, "git", "status", "--porcelain", "--untracked-files=no") + if err != nil { + return err + } + if status != "" { + return errors.New("tracked files changed during the check; commit the fixes and repeat just signoff") + } + return nil +} + +func signoffEnv() []string { + var env []string + for _, entry := range os.Environ() { + if !strings.HasPrefix(entry, "JUST_") && !strings.HasPrefix(entry, "GIT_") { + env = append(env, entry) + } + } + return env +} + +func signoffCommand(ctx context.Context, env []string, dir, name string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir, cmd.Env = dir, env + // Cancel the group too, so a stopped check cannot leave a child using the worktree. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = 5 * time.Second + return cmd +} + +func signoffOutput(ctx context.Context, env []string, dir, name string, args ...string) (string, error) { + output, err := signoffCommand(ctx, env, dir, name, args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, output) + } + return strings.TrimSpace(string(output)), nil +} + +func signoffExit(err error) int { + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() >= 0 { + return exitErr.ExitCode() + } + return 1 +} diff --git a/sdk/extensionrepo/signoff_receipt.go b/sdk/extensionrepo/signoff_receipt.go new file mode 100644 index 00000000..00dc668c --- /dev/null +++ b/sdk/extensionrepo/signoff_receipt.go @@ -0,0 +1,61 @@ +package extensionrepo + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/pascalandy/nicely/sdk/config" +) + +type signoffCheck struct { + Command []string `json:"command"` + Exit int `json:"exit"` +} + +type signoffReceipt struct { + SHA string `json:"sha"` + OS string `json:"os"` + Tools map[string]string `json:"tools"` + Checks []signoffCheck `json:"checks"` + Signoff *signoffCheck `json:"signoff"` +} + +func (r signoffReceipt) write(run string) error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(run, "receipt.json"), append(data, '\n'), 0o600) +} + +func newSignoffRun(root, sha string) (string, error) { + cache := config.Locate(os.Getenv("HOME"), os.LookupEnv).Cache + if !filepath.IsAbs(cache) { + return "", errors.New("signoff needs an absolute cache folder outside the repository") + } + // Resolve symlinks after creation, so a cache link cannot place receipts inside tracked files. + base := filepath.Join(cache, "signoff") + if err := os.MkdirAll(base, 0o700); err != nil { + return "", err + } + base, err := filepath.EvalSymlinks(base) + if err != nil { + return "", err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, base) + if err != nil { + return "", err + } + if rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New("cache folder is inside the repository; set XDG_CACHE_HOME outside it and repeat just signoff") + } + return os.MkdirTemp(base, time.Now().UTC().Format("20060102T150405Z")+"-"+sha+"-") +} diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index b45e7b25..906ff774 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -43,10 +43,12 @@ A wrapped script goes in `worker/`. To take in a script, or to review the extens ## Recipes The recipes need Go, `just`, and `uv`, which runs the pinned Copier of `just update`. The scenarios of `inspect` run `/usr/bin/python3`, 3.9 or later. +Signoff also needs Git and `gh` with the `gh-signoff` extension. After Copier creates the repository, run `go mod tidy` once, which writes `go.sum` for the recipes. Then commit the repository with Git, since `just update` needs a clean Git tree. Run `lefthook install` once per clone, so `just check` runs before each push. GitHub runs it again for each push to `main` and each pull request. - `just check`: every check. It runs `go mod tidy -diff`, `gofmt`, `go vet`, and `go test`, whose test kit installs the extension into scratch folders +- `just signoff`: checks the committed SHA in a detached scratch worktree, writes a receipt outside the repository, and signs that exact SHA - `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 @@ -64,9 +66,10 @@ A release is one Git tag `vX.Y.Z` of Semantic Versioning, such as `v0.1.0`. "Release and install" in the contract's `extensions.md` holds these rules. 1. Commit every change -2. Run `just check` on the clean Git tree -3. Make sure that "Done when" holds for this commit -4. Push the commit to the default branch of the remote repository -5. Tag that commit, such as with `git tag v0.1.0` -6. Push the tag, such as with `git push origin v0.1.0` -7. Run the commands of `README.md` for the new tag in a new folder. `ncly describe {{ root }} --json` must describe `{{ root }}` +2. Push that commit to a review branch +3. Run `just signoff` for that pushed commit +4. Make sure that "Done when" holds for this commit +5. Merge that exact signed SHA into the default branch +6. Tag that commit, such as with `git tag v0.1.0` +7. Push the tag, such as with `git push origin v0.1.0` +8. Run the commands of `README.md` for the new tag in a new folder. `ncly describe {{ root }} --json` must describe `{{ root }}` diff --git a/template/justfile b/template/justfile index 5741d8b0..7763ff21 100644 --- a/template/justfile +++ b/template/justfile @@ -19,6 +19,10 @@ check: go vet ./... go run ./internal/export test +# 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 install: go run ./internal/export install From e3cff2733f4ce4a844bc2088a2cf2383057a658b Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Thu, 29 Oct 2026 23:37:07 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A7=AA=20test:=20signoff:=20disable?= =?UTF-8?q?=20fixture=20commit=20signing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: keep global signing settings out of fixture commits - Impact: signoff regressions run without a configured signer --- scripts/signoff-test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/signoff-test.sh b/scripts/signoff-test.sh index 99fe57e5..0ebb613a 100644 --- a/scripts/signoff-test.sh +++ b/scripts/signoff-test.sh @@ -72,6 +72,7 @@ JUST git -C "${root}/caller" config user.name 'Signoff test' git -C "${root}/caller" config user.email 'test@example.com' git -C "${root}/caller" config core.hooksPath /dev/null + git -C "${root}/caller" config commit.gpgsign false printf 'broken\n' >"${root}/caller/fix.txt" git -C "${root}/caller" add . git -C "${root}/caller" commit -qm 'broken commit' From cd22fd112741925317755d9952acb473810d0822 Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Thu, 29 Oct 2026 23:37:15 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=9A=91=20fix:=20signoff:=20isolate=20?= =?UTF-8?q?Go=20source=20and=20workspace=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: prevent dirty overlays from signing a broken committed SHA - Impact: ignore caller Go flags, workspace modules, and persisted settings --- docs/north-star/decisions/D016-local-ci.md | 2 ++ scripts/signoff-test.sh | 38 ++++++++++++++++++++-- sdk/extensionrepo/signoff.go | 12 +++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/north-star/decisions/D016-local-ci.md b/docs/north-star/decisions/D016-local-ci.md index ca0c125a..6ddd519b 100644 --- a/docs/north-star/decisions/D016-local-ci.md +++ b/docs/north-star/decisions/D016-local-ci.md @@ -7,6 +7,8 @@ Decided 2026-10-05. Changed 2026-10-29 by [#373](https://github.com/pascalandy/n `just signoff` uses `sdk/extensionrepo` in core and extension repositories. The program freezes the full HEAD SHA and creates a detached scratch worktree at that SHA. It clears `JUST_*` and `GIT_*` from its child environment before it resolves the repository or runs a check. +It removes caller `GOFLAGS`, whose overlays and module flags can replace committed source or dependencies. +It disables `GOWORK`, which can select external workspace modules, and `GOENV`, which can restore caller Go settings. It runs that worktree's `just check` once, including its dependencies. A failed check, a changed HEAD, or a changed tracked file prevents signoff. Uncommitted changes in the caller cannot fix the checked commit. diff --git a/scripts/signoff-test.sh b/scripts/signoff-test.sh index 0ebb613a..690e256e 100644 --- a/scripts/signoff-test.sh +++ b/scripts/signoff-test.sh @@ -25,7 +25,7 @@ fct_setup() { done repo_root="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd -P)" root="$(mktemp -d "${TMPDIR:-/tmp}/ncly-signoff-test.XXXXXXXX")" - mkdir -p "${root}/bin" "${root}/caller/scripts" "${root}/cache" + mkdir -p "${root}/bin" "${root}/caller/scripts" "${root}/caller/signoff-fixture" "${root}/cache" export NCLY_SIGNOFF_TEST="${root}" PATH="${root}/bin:${PATH}" XDG_CACHE_HOME="${root}/cache" cat >"${root}/bin/gh" <<'SCRIPT' #!/usr/bin/env bash @@ -64,10 +64,23 @@ signoff-test-check: @git rev-parse HEAD >>"${NCLY_SIGNOFF_TEST}/checked" @test -z "${GIT_DIR-}${GIT_WORK_TREE-}${GIT_SIGNOFF_TEST-}${JUST_SIGNOFF_TEST-}${JUST_NO_DEPS-}" @test "$(cat fix.txt)" = fixed || { echo COMMITTED-FAILURE >&2; exit 1; } + @go test -count=1 ./signoff-fixture @if [[ -e "${NCLY_SIGNOFF_TEST}/move-head" ]]; then git checkout --quiet --detach HEAD~; fi @if [[ -e "${NCLY_SIGNOFF_TEST}/change-tracked" ]]; then echo changed >fix.txt; fi @if [[ -e "${NCLY_SIGNOFF_TEST}/move-caller" ]]; then git -C "${NCLY_SIGNOFF_TEST}/caller" -c commit.gpgsign=false commit --quiet --allow-empty --message 'caller moved'; fi JUST + printf 'package fixture\n\nfunc fixed() bool { return false }\n' >"${root}/caller/signoff-fixture/fix.go" + cat >"${root}/caller/signoff-fixture/fix_test.go" <<'GO' +package fixture + +import "testing" + +func TestFix(t *testing.T) { + if !fixed() { + t.Fatal("COMMITTED-FAILURE") + } +} +GO git -C "${root}/caller" init -q git -C "${root}/caller" config user.name 'Signoff test' git -C "${root}/caller" config user.email 'test@example.com' @@ -101,6 +114,7 @@ fct_main() { trap fct_cleanup EXIT test_case="dirty fix" printf 'fixed\n' >"${root}/caller/fix.txt" + printf 'package fixture\n\nfunc fixed() bool { return true }\n' >"${root}/caller/signoff-fixture/fix.go" (cd "${root}/caller" && just check) >"${root}/output" 2>&1 fct_run fails just signoff grep -qF COMMITTED-FAILURE "${root}/output" || fct_fail 'the committed defect did not fail the check' @@ -110,10 +124,30 @@ fct_main() { git -C "${root}/caller" add fix.txt git -C "${root}/caller" commit -qm 'committed fix' sha="$(git -C "${root}/caller" rev-parse HEAD)" + test_case="dirty Go fix without overlay" + fct_run fails just signoff + grep -qF COMMITTED-FAILURE "${root}/output" || fct_fail 'the committed Go defect did not fail the check' + [[ ! -s "${root}/signed" ]] || fct_fail 'signed the unfixed Go commit' + fct_receipt 1 null + jq -n --arg fixed "${root}/caller/signoff-fixture/fix.go" '{Replace:{"signoff-fixture/fix.go":$fixed}}' >"${root}/overlay.json" + printf 'GOFLAGS=-overlay=%s/overlay.json\n' "${root}" >"${root}/go-env" + local form + for form in GOFLAGS GOENV; do + test_case="dirty Go fix through ${form}" + case "${form}" in + GOFLAGS) fct_run fails env GOFLAGS="-overlay=${root}/overlay.json" just signoff ;; + GOENV) fct_run fails env GOENV="${root}/go-env" just signoff ;; + esac + grep -qF COMMITTED-FAILURE "${root}/output" || fct_fail 'the overlay bypassed the committed Go defect' + [[ ! -s "${root}/signed" ]] || fct_fail 'signed the overlay-fixed commit' + fct_receipt 1 null + done + git -C "${root}/caller" add signoff-fixture/fix.go + git -C "${root}/caller" commit -qm 'committed Go fix' + sha="$(git -C "${root}/caller" rev-parse HEAD)" printf 'dirty caller\n' >"${root}/caller/fix.txt" printf 'untracked caller\n' >"${root}/caller/untracked.txt" git -C "${root}/caller" diff >"${root}/before" - local form mkdir "${root}/decoy" git -C "${root}/decoy" init -q printf 'check:\n @exit 1\n' >"${root}/decoy/justfile" diff --git a/sdk/extensionrepo/signoff.go b/sdk/extensionrepo/signoff.go index 45cbe9cd..c6d662c0 100644 --- a/sdk/extensionrepo/signoff.go +++ b/sdk/extensionrepo/signoff.go @@ -122,11 +122,17 @@ func signoffUnchanged(ctx context.Context, env []string, worktree, sha string) e func signoffEnv() []string { var env []string for _, entry := range os.Environ() { - if !strings.HasPrefix(entry, "JUST_") && !strings.HasPrefix(entry, "GIT_") { - env = append(env, entry) + name, _, _ := strings.Cut(entry, "=") + if strings.HasPrefix(name, "JUST_") || strings.HasPrefix(name, "GIT_") { + continue } + switch name { + case "GOFLAGS", "GOWORK", "GOENV": + continue + } + env = append(env, entry) } - return env + return append(env, "GOWORK=off", "GOENV=off") } func signoffCommand(ctx context.Context, env []string, dir, name string, args ...string) *exec.Cmd { From 408e52a079b9365c86e788b45642e721fb724685 Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Thu, 29 Oct 2026 23:37:25 -0400 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=9A=20docs:=20signoff:=20explain?= =?UTF-8?q?=20recovery=20after=20SIGKILL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: recover a scratch worktree after an uncatchable signal - Impact: name the removal command and its path from the printed receipt --- docs/north-star/decisions/D016-local-ci.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/north-star/decisions/D016-local-ci.md b/docs/north-star/decisions/D016-local-ci.md index 6ddd519b..376f1b35 100644 --- a/docs/north-star/decisions/D016-local-ci.md +++ b/docs/north-star/decisions/D016-local-ci.md @@ -22,6 +22,7 @@ The current gate is `just check`. The receipt exists before signoff and then records the exit code of `gh signoff`. The run also keeps its check log, including failures. The scratch worktree goes when the program returns. +After SIGKILL, run `git worktree remove --force /worktree` from the caller, using the printed receipt's folder. Every run repeats the checks, even when a receipt already exists. **Rejected.** Local checks alone, because a skipped hook then leaves a broken `main` unseen, as #373 found. GitHub Actions as the only gate, because Pascal often hits its limits, and local checks are faster. A required `check` status, because `signoff` already gates `main`. From 78963f6c7fae01281197f862b3e67349c20b7673 Mon Sep 17 00:00:00 2001 From: Pascal Andy Date: Thu, 29 Oct 2026 23:55:22 -0400 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=9A=91=20fix:=20signoff:=20keep=20per?= =?UTF-8?q?sisted=20Go=20settings=20and=20mask=20persisted=20GOFLAGS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Purpose: Let signoff use settings such as GOPRIVATE that go env -w keeps - Impact: A blank GOFLAGS masks a persisted overlay, and GOENV is no longer turned off Co-Authored-By: Claude Opus 5.5 --- docs/north-star/decisions/D016-local-ci.md | 5 +++-- scripts/signoff-test.sh | 8 +++++++- sdk/extensionrepo/signoff.go | 6 ++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/north-star/decisions/D016-local-ci.md b/docs/north-star/decisions/D016-local-ci.md index 376f1b35..e1d3c8a8 100644 --- a/docs/north-star/decisions/D016-local-ci.md +++ b/docs/north-star/decisions/D016-local-ci.md @@ -7,8 +7,9 @@ Decided 2026-10-05. Changed 2026-10-29 by [#373](https://github.com/pascalandy/n `just signoff` uses `sdk/extensionrepo` in core and extension repositories. The program freezes the full HEAD SHA and creates a detached scratch worktree at that SHA. It clears `JUST_*` and `GIT_*` from its child environment before it resolves the repository or runs a check. -It removes caller `GOFLAGS`, whose overlays and module flags can replace committed source or dependencies. -It disables `GOWORK`, which can select external workspace modules, and `GOENV`, which can restore caller Go settings. +It replaces caller `GOFLAGS` with a blank value, which also masks a `GOFLAGS` that `go env -w` keeps. Overlays and module flags can replace committed source or dependencies. +It disables `GOWORK`, which can select external workspace modules. +Other persisted Go settings, such as `GOPRIVATE`, stay. It runs that worktree's `just check` once, including its dependencies. A failed check, a changed HEAD, or a changed tracked file prevents signoff. Uncommitted changes in the caller cannot fix the checked commit. diff --git a/scripts/signoff-test.sh b/scripts/signoff-test.sh index 690e256e..729acc7a 100644 --- a/scripts/signoff-test.sh +++ b/scripts/signoff-test.sh @@ -65,6 +65,7 @@ signoff-test-check: @test -z "${GIT_DIR-}${GIT_WORK_TREE-}${GIT_SIGNOFF_TEST-}${JUST_SIGNOFF_TEST-}${JUST_NO_DEPS-}" @test "$(cat fix.txt)" = fixed || { echo COMMITTED-FAILURE >&2; exit 1; } @go test -count=1 ./signoff-fixture + @go env GOPRIVATE >"${NCLY_SIGNOFF_TEST}/goprivate" @if [[ -e "${NCLY_SIGNOFF_TEST}/move-head" ]]; then git checkout --quiet --detach HEAD~; fi @if [[ -e "${NCLY_SIGNOFF_TEST}/change-tracked" ]]; then echo changed >fix.txt; fi @if [[ -e "${NCLY_SIGNOFF_TEST}/move-caller" ]]; then git -C "${NCLY_SIGNOFF_TEST}/caller" -c commit.gpgsign=false commit --quiet --allow-empty --message 'caller moved'; fi @@ -151,7 +152,8 @@ fct_main() { mkdir "${root}/decoy" git -C "${root}/decoy" init -q printf 'check:\n @exit 1\n' >"${root}/decoy/justfile" - for form in normal no-deps-env no-deps-flag caller-env; do + printf 'GOPRIVATE=example.com/private\n' >"${root}/go-env-private" + for form in normal no-deps-env no-deps-flag caller-env caller-go-env; do test_case="passing commit, ${form}" case "${form}" in normal) fct_run passes just signoff ;; @@ -162,6 +164,10 @@ fct_main() { JUST_SIGNOFF_TEST=leak JUST_NO_DEPS=true JUST_JUSTFILE="${root}/decoy/justfile" JUST_WORKING_DIRECTORY="${root}/decoy" \ just --justfile "${root}/caller/justfile" --working-directory "${root}/caller" signoff ;; + caller-go-env) + fct_run passes env GOENV="${root}/go-env-private" just signoff + [[ "$(cat "${root}/goprivate")" == example.com/private ]] || fct_fail 'signoff dropped a persisted Go setting' + ;; esac [[ "$(cat "${root}/signed")" == "signoff --commit ${sha}" ]] || fct_fail 'gh did not receive the frozen full SHA' [[ "$(cat "${root}/signed-head")" == "${sha}" ]] || fct_fail 'gh ran outside the frozen checkout' diff --git a/sdk/extensionrepo/signoff.go b/sdk/extensionrepo/signoff.go index c6d662c0..9ee19303 100644 --- a/sdk/extensionrepo/signoff.go +++ b/sdk/extensionrepo/signoff.go @@ -127,12 +127,14 @@ func signoffEnv() []string { continue } switch name { - case "GOFLAGS", "GOWORK", "GOENV": + case "GOFLAGS", "GOWORK": continue } env = append(env, entry) } - return append(env, "GOWORK=off", "GOENV=off") + // A blank GOFLAGS also masks one persisted by go env -w, which an empty value does not. + // Other persisted settings, such as GOPRIVATE, stay. + return append(env, "GOWORK=off", "GOFLAGS= ") } func signoffCommand(ctx context.Context, env []string, dir, name string, args ...string) *exec.Cmd {