release-train: staging -> main - #662
Merged
Merged
Conversation
…verable mirror (#654) * ci(mirror): fail-closed publish guard + publisher for the public deliverable mirror The public face of this repo becomes a separate, deliverable-only mirror: README/LICENSE/docs on its branch and each release with its signed assets. No Go source ever leaves. Nothing reaches the mirror except through the new Mirror publish workflow, and nothing leaves that workflow except what scripts/publish-guard.sh staged from the explicit allowlist (.publish-include) and cleared through four guards: forbidden paths and forbidden strings (.publish-forbidden), and gitleaks. Every guard fails closed; "could not tell" never publishes. scripts/publish-mirror.sh does the push: it refuses an unset mirror and a mirror equal to this repository, pushes plainly (never force), and never overwrites an existing release. The workflow runs after Release completes and on dispatch with dry-run defaulting to true; the mirror name (MIRROR_REPO) has no default. Customer identifiers for the string scan are supplied privately at publish time, not committed to this public file. build.yml's Installer job shellchecks the scripts and runs both harnesses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(mirror): split the string guard into refuse and report tiers A guard that can never pass is a guard nobody arms: on the real tree the single [strings] tier refused with 26 hits (README and the two installers), all of them internal ticket identifiers and non-production hostnames whose fate is still a decision, not a defect. The scan now has two tiers in .publish-forbidden: - [strings-refuse] mailboxes (support@ spared), arn:aws:, ECR account ids, plus the private tenant needles from --extra-forbidden. A hit refuses, as before. - [strings-report] ticket/RFC identifiers and non-production hosts. Hits are counted and printed (per-needle totals, ten most-hit files) but refuse only under --strict. The list itself is refused (exit 2) on an unknown section header, a needle in both tiers, or an empty [strings-refuse]. The header regex is loose on purpose so a misspelt header is refused by name rather than read as a needle of the section before it. The workflow gains a `strict` dispatch input (default false) and honours the PUBLISH_STRICT repository variable for every run, including workflow_run, which has no inputs; flipping either arms the report tier. Tests: 34 -> 43 (42 in CI, where the real-gitleaks case skips). Report-tier hit alone -> exit 0 with count and table; the same under --strict -> exit 1 naming [strings-report (strict)]; refuse-tier hit -> exit 1 naming [strings-refuse]; duplicate needle, unknown section and empty refuse tier -> exit 2 with the offender named. The committed list is exercised needle by needle with inputs written in the test, and the real tree is now asserted clean (exit 0). Each new check was mutation-proved. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(mirror): run the tooling from this commit, treat the tag as data, keep prereleases off the default branch Review findings on the mirror-publish workflow, each with its test: - Untrusted checkout. The job checked out the release tag and then ran scripts/publish-guard.sh and publish-mirror.sh from it while the job went on to mint an App token. Now the one actions/checkout has no ref (the workflow's own commit), and the release tag is fetched separately into a detached worktree under RUNNER_TEMP as data — staged and scanned, never executed. Before the fetch the plan step requires the release object's tag_name to equal the run's tag and takes the expected commit from workflow_run.head_sha (a dispatch asks the API); the fetched tag must resolve to exactly that commit or the run refuses. The guard reads the allowlist and forbidden list from the tooling checkout, whatever the tag carries. - Prerelease overwrote the mirror's default branch. workflow_run always publishes, and the tree push never looked at PRERELEASE. The plan step now derives publish_tree=false for a prerelease and says why; the default branch push is gated on it; the release is still created, marked prerelease, pinned to the mirror's current default-branch head — an empty mirror is refused rather than given an RC as its first content. - Captured output hid refusals. `target` and `tree` ran through `$(...)`, so under set -e their ::error:: lines never reached the log. Both now run directly and write their results (repo=/name=, result=/sha=) through a new --output FILE option, which the workflow points at $GITHUB_OUTPUT. - The gitleaks download carries --tlsv1.2 like every other privileged fetch in this repository. scripts/tests/mirror-publish-workflow-verify.sh executes the plan, src, target and keep step bodies read out of the workflow itself (gh shimmed, the tag fetch against a real bare repo) and pins the shape — no checkout ref, the tree push gated, the release step not, no captured publisher, one pinned tag fetch — with mutations asserted to change the document before they are judged; build.yml runs it beside the other harnesses. publish-mirror-verify.sh covers --output for target and tree, including that a refusal writes nothing and annotates stdout. Each new check was mutation-proved against the real workflow and script. The release checklist notes the trust shape and the prerelease behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(mirror): pin the fixture origin's HEAD so the tag-fetch case runs on a fresh runner The workflow harness's bare origin relied on init.defaultBranch: unset on the runner, its HEAD pointed at a `master` nobody pushed, the clone had an unborn HEAD, and `rev-parse HEAD` handed the src step the literal word HEAD as the expected commit — the case failed with "cut at HEAD" instead of proving the pinned fetch (Installer (shell) on the previous push). The bare HEAD is now set to main explicitly and the commit is read from the seed repository; reproduced locally with GIT_CONFIG_KEY_0=init.defaultBranch GIT_CONFIG_VALUE_0=master before and after. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(guard): strip [allow] tokens as whole words, name private needles by number only Two review findings on scripts/publish-guard.sh (kept byte-identical with the client repository's copy): - The [allow] pass removed a token with an unanchored, case-sensitive sed replace before re-testing the needle. A mailbox that merely ENDS in the public support address (devsupport@…) lost the token, the mailbox rule no longer matched, and the internal address could ship; a differently-cased public address was refused. The strip now matches the token as a whole word — not the tail of a longer local part, not the head of a longer domain, a sentence-ending dot still a boundary — and case-insensitively, as the scan itself matches. - Refuse-tier hits printed the needle, and the workflow tees that log into the run summary. The needles from --extra-forbidden are the identifiers kept out of the committed list because this repository is public, so one deliverable hit would have published them in the log. Private needles are now scanned in their own pass and named `private needle #N` in every line the guard prints or writes (stdout, the report file, grep-error text); the committed needles are still named by pattern. publish-guard-verify.sh: devsupport@ is refused, Support@Tracebloc.io. at a sentence end passes, and the tenant case asserts the redacted name and that the pattern appears neither in the output nor in publish-guard-report.txt. Each check was mutation-proved by restoring the old strip and the old label in the script and watching only its named cases redden. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mirror): push the tree only for the newest stable release Any non-prerelease tag set publish_tree=true, so a Release re-run or a dispatch of an OLDER stable tag replaced the mirror's default branch with that tag's README and docs — and, with the release already mirrored, refused right after, leaving the rollback in place and paired with nothing. The plan step now asks GitHub for the source repo's newest stable release (releases/latest) whenever the tag is not a prerelease. A tag that is not that release mirrors only its GitHub release, pinned like a prerelease to the default branch's current head, and the log says why. If the newest release cannot be read the run refuses rather than guess. The keep step's name and messages cover both cases now. mirror-publish-workflow-verify.sh: the gh shim answers releases/latest from GH_LATEST_TAG / GH_LATEST_RC; new cases for an older stable tag (publish_tree=false), an unreadable newest release (refused), a prerelease not consulting it, and a plan-body mutation that disarms the comparison — the older-tag case catches it. 20 cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * ci(mirror): refuse a non-boolean isPrerelease; keep the guard summary under errexit Two hardenings in the plan and guard steps of mirror-publish.yml: - isPrerelease from the release API must be exactly `true` or `false`. A missing or malformed value (`jq -r` prints `null`) used to fall through into the stable path and arm the tree push; it is now refused before the newest-release question is asked. - The guard step has a stable id (guard-tree) and catches the guard's exit status with `rc=0; ... | tee ... || rc=$?`. Actions runs the body under `bash -e`, so the earlier `rc=${PIPESTATUS[0]}` never ran on a refusal and the step summary stayed empty; the step still exits with the guard's own status. mirror-publish-workflow-verify.sh runs every step body under `bash -e` (as Actions does) and pins both: the null case, the refusal-to-summary case, and a mutation for each (accept any isPrerelease -> null publishes the tree; drop `|| rc=$?` -> summary empty) that the new cases catch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
release-train: develop -> staging
…CEBLOC_ prefix (#657) * feat(env): alias-first TRACEBLOC_ENV stage var, normalize toward RFC-0076 Normalize this repo's owned stage-selecting env var toward the RFC-0076 canon (backend#3391), alias-first — read new-or-old, never break an existing deployment. TRACEBLOC_ENV is now the canonical name; the legacy CLIENT_ENV is read as a fallback (remove_by: 2026-12-31). - api.ResolveEnv: --env flag, then $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod. Alias precedence lives in one place (stageFromEnv). - doctor: the cluster's stage, read off the jobs-manager Deployment spec, is now read alias-first (stageFromClusterSpec) — a consumer-side alias so the edge chart can adopt the canonical key on its own S3-edge timeline. - auth login / status --check help + the unknown-env error name the canonical var (legacy noted); goldens regenerated. - env-resolution guard registers TRACEBLOC_ENV as a needle so a new read of either name lands in the allowlist; TestMain clears both stage vars so the higher-precedence canonical name can't make CLIENT_ENV-only isolation flaky. - VERSION 0.10.24 -> 0.10.25 (version-bump-gate: env reads are packaged paths). The other RFC-0076 config keys (registry REGISTRY_URL, telemetry, boolean gates) have no owned, unprefixed occurrence here: TRACEBLOC_ALLOW_UNVERIFIED already carries the prefix and no REGISTRY_URL/SKIP_TELEMETRY var exists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): errcheck TestMain os.Unsetenv + bump VERSION 0.10.26 after develop merge (cli#656) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… flow (#651) (#658) * fix(login): reuse a valid session instead of always starting a device flow (cli#651) `tracebloc login` went straight to a device code even when the machine already held a valid session for the target env. On a headless host that is a dead end rather than an inconvenience: the credentials are on disk, but the command insists on a browser approval it has no way to complete — turning "re-run login to be safe" into a hard stop for any script or runbook. login now checks the session it already has before asking for one: - A session for the target env that the backend accepts ends the command at exit 0 with "Already signed in as <account>". The session is confirmed with a live WhoAmI rather than trusted off disk — a revoked token is still a token on disk. - `--force` is the opt-out (switching accounts, replacing a session believed stale) and skips the short-circuit entirely, including the probe. Same sense as `delete --force`: proceed despite the state that would otherwise stop you. - Every fall-through says WHY first, and only claims what it can tell apart: a LOCAL expires_at that has passed is named to the second (and is not presented to the backend), a 401/403 is reported as rejected, and anything else — DNS, a 5xx — is "couldn't check", not a verdict on the session. A 426 surfaces the upgrade instruction and starts no flow, since a fresh flow would hit the same version floor. "A session for this env" is resolved two ways, so credentials already on disk aren't stranded: the current session when sessionEnv resolves it to the target (the same predicate `auth status --check` uses, so login and the installer's probe cannot disagree), else that env's own profile — a machine on prod can hold a live dev token, and `login --env dev` adopts it and switches current_env. The profile is written back under the key it was found under, so a v1-migrated `"Dev"` config doesn't gain a second, lower-cased profile beside the real one. Tests cover each arm by asserting whether /device/code was requested at all — the browser demand is the behaviour that matters. Goldens regenerated for the new --force flag and copy. Closes #651 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(release): bump VERSION to 0.10.26 for the login short-circuit (cli#651) version-bump-gate failed on the previous head: VERSION still read 0.10.25, v0.10.25 is already released, and this PR changes a published file (internal/cli/auth.go, matching `internal/*`). The release train cuts the tag from this file and never bumps it, so leaving it stale doesn't fail here — it fails the next prod hop, days later, on somebody else (backend#1561). 0.10.26 is free: v0.10.25 is the highest released final version and no v0.10.26 tag exists. Patch, not minor — this ships one bug fix, and the new `login --force` flag is additive with no change to any existing invocation. (Open PR #657 also touches VERSION, but bumps 0.10.24 -> 0.10.25, which is already released — its gate is red for the same reason and it needs a rebase. It does not claim 0.10.26.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(login): find raw-keyed profiles, and exit 130 on a cancelled probe (cli#651) Two Bugbot findings on PR #658, both real. 1. storedSessionFor's second arm indexed cfg.Profiles with the ALREADY NORMALISED target env, so a live token written under a raw key such as `"Dev"` (config.migrateV1 stores a v1 `env` verbatim) went unseen the moment that profile stopped being the current one. `login --env dev` then ran a device flow and saved a SECOND profile under `"dev"`, stranding a perfectly good session beside it — on exactly the headless host this issue is about. The existing raw-key test could not see it: it keeps `"Dev"` CURRENT, which arm 1 catches before the map lookup is reached. The gap only opens after a `login --env` elsewhere has moved current_env. Lookup now folds the map's own KEYS (new profileKeyed). Exact match wins; the fold is a tie-break scanned in sorted order, so a config holding both `"Dev"` and `"dev"` cannot answer differently run to run on Go's randomised map iteration. The trim+lower-case is extracted from sessionEnv as normalizeEnv and shared, rather than hand-rolled a second time — a second copy is how the keys stop matching in the first place. 2. A cancelled context surfaces on the WhoAmI call as a plain error, so Ctrl-C during the new probe landed in the "couldn't check" arm: it printed "signing in again" and then failed RequestDeviceCode with exit 1, where every other interrupt in login exits 130 silently. Guarded on ctx.Err() before the classification — the same guard, for the same reason, as pollForToken's. Tests: the not-current raw-key case (asserts no flow, no duplicate profile, current_env set to the FOUND key), a determinism test running profileKeyed 50x over a config with three case variants, and a cancelled probe asserting exit 130, silence, and zero device codes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(login): one shared WhoAmI classifier for both session probes (cli#651) Review on PR #658: reuseStoredSession's 426 / 401-403 / everything-else classification of a WhoAmI failure duplicated the identical block in runAuthCheck a few dozen lines down. The duplication is one this PR introduced, so it is fixed here rather than left for a follow-up — "fix the class, not the instance". classifyWhoAmIError returns a named whoAmIVerdict (whoAmIUnverified / whoAmIRejected / whoAmIUpgradeRequired) plus the *api.UpgradeRequiredError, so the caller surfaces the server's own version floor rather than a paraphrase. The three arms are deliberately not collapsible: only whoAmIRejected is a statement about the credential. A 5xx folded into it would tell someone to re-authenticate during an outage, and a 426 folded into it would send them to a browser step that cannot lift a version floor. The COPY stays at the call sites. The two commands answer different questions — "should I start a device flow?" vs "what is this exit code?" — and say so in different words; only the classification is shared. No user-facing string changed, and the goldens confirm it (regenerated, no diff). Test: a table over 401/403/426/500/404/429, a transport error, a cancelled context, and wrapped 401/426 — wrapped because both call sites receive the error through the api client's own fmt.Errorf wrapping, so matching the concrete type alone would silently demote every real verdict to "unverified". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
release-train: develop -> staging
…gs, newest 10 binaries) (#659) * feat(scripts): one-shot release backfill to the public mirror (all tags, newest 10 binaries) scripts/backfill-releases.sh carries this repository's historical releases onto the public deliverable mirror once; mirror-publish.yml covers every release cut after the mirror exists. Decision: every published release gets its tag, its GitHub release and its text assets (install.sh, install.ps1, SHA256SUMS, anything SHA256SUMS does not list); binaries and their .sig/.cert only for the newest BINARY_KEEP (default 10) releases. Mirror tags are annotated release markers on the mirror's default-branch head carrying the original date and message, since the mirror has no source commit to point at. Reuses publish-mirror.sh `target` for the mirror-name rule and publish-guard.sh for the string scan of every text asset and release body; binaries are verified against the source SHA256SUMS before upload. Fails closed (exit 2 on any incomplete read), refuses per release (exit 1), idempotent (a re-run over a complete mirror writes nothing), resumable (--from-tag / --only-tag). Dry-run is the default. scripts/tests/backfill-releases-verify.sh: 30 offline assertions against a recording fake gh, plus --mutations proving 6 anchored rules load-bearing. Both wired into the Installer (shell) job. RELEASE_CHECKLIST.md gains §8. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(scripts): backfill defaults to the workflow's fixed release notes; --notes source is the opt-in Historical release bodies are GitHub's generated pull-request lists, and nearly every one carries strings the publish guard's report tier counts. The public mirror should not repeat them, so the backfill now writes the same fixed notes mirror-publish.yml writes for new releases by default, plus the original-date footer. `--notes source` still carries the source body on explicit request and runs it through the guard as before. Tests: the default path now expects fixed notes and no trace of the source body; `--notes source` has its own positive test; the bad-body refusal and the --strict report-tier refusal run under `--notes source`, with a paired default-notes run proving the body is never staged. The mutation list gains `notes-default-fixed` (flips the default back), which reddens four tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(scripts): die2 reports on stderr so a could-not-tell inside $(...) is seen, not swallowed Nearly every jq_of call sits inside a "$(...)" assignment. die2 wrote its ::error:: reason to stdout, so inside the substitution the reason was captured into the variable and never printed; the subshell's exit 2 ended only the subshell, and the parent died under set -e with a bare status 2 the operator could not name. The prerelease read sat inside a "[ ... ]" test in an && list, where set -e is suspended, so that failure did not end the run at all - a malformed release.json read as "stable". - die2 writes to stderr: the reason reaches the operator from any depth, and the substitution's status 2 still aborts the assignment under set -e. - the prerelease jq_of is hoisted into its own assignment before the test. - harness: the fake gh gains FAKE_GH_GARBLE_RE (a call that "succeeds" with a non-JSON body); a new case pins that a garbled read parsed inside a substitution is exit 2 naming the file and filter with the reason in the output; a die2-stderr mutation (stdout die2) must redden it - proven: the mutant reddens exactly that case, 8 caught / 0 survived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(scripts): backfill marks a surviving stable release latest when the newest is refused; scratch commit is never signed Two review findings, both confirmed against the script: 1. make_latest=true travelled only on the newest stable release's own POST, every older release being created with make_latest=false. When the newest stable was refused before that POST (SHA mismatch, guard hit, dangling tag) nothing on the mirror was marked latest and releases/latest answered 404 until a human noticed the exit 1 and re-ran --only-tag. After the loop, an --apply run whose newest stable was refused AT CREATE TIME now PATCHes the newest stable release it did write to make_latest=true and says so; a refusal of a release the mirror already has leaves latest untouched, and a run that wrote no stable release warns and names the re-run. The happy path is unchanged (still 48 writes for 12 releases). 2. The guard's scratch checkout committed with only user.name/user.email overrides; a global commit.gpgsign=true on the operator's machine would try to sign as backfill@localhost, fail, and end the run before a release was planned. The scratch commit is never published: -c commit.gpgsign=false. Harness: the fake gh now returns real release ids and serves PATCH repos/<mirror>/releases/<id>; writes() counts PATCH; the sha-mismatch case (which refuses v0.1.11, the newest stable) pins exactly one PATCH marking v0.1.10 latest; a new case runs under GIT_CONFIG_GLOBAL with gpgsign=true and a failing signer and demands a clean plan. Two mutations (latest-fallback, scratch-commit-unsigned) each redden exactly their case: 37 passed, 10 caught / 0 survived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(scripts): the latest fallback sends make_latest as the string the API takes, and the fake gh refuses a typed one The fallback PATCH added in the previous commit used -F make_latest=true, which gh types as a JSON boolean. The releases API takes make_latest as a string enum ("true"/"false"/"legacy") - the create path two lines away uses -f for exactly that reason - so the PATCH would 422, die2 would fire, and the one scenario the fallback exists for ended with the fallback failing closed and releases/latest still 404ing. Now -f, matching the create path. The harness let this through because the fake gh treated -f and -F alike. It now records which fields arrived typed and answers a typed make_latest on either release call with the API's 422, so the type is enforced where the real endpoint enforces it. A latest-string-typed mutation (the -F line) reddens the sha-mismatch and fallback cases: 37 passed, 11 caught / 0 survived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
release-train: develop -> staging
Contributor
Author
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 01ec0c7. Configure here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automated promotion by the release train (RFC-0008 D14). Head is the train-managed
release-train/to-mainbranch (a mirror ofstaging), so it never collides with a human PR. Merged only when the fr-gate is green.Note
High Risk
New publish path moves release assets to a separate repo with secret-backed string scans and write tokens; login/session and env resolution changes affect every authenticated command.
Overview
Adds a fail-closed public mirror pipeline: after Release completes,
mirror-publish.ymlstages only allowlisted docs (.publish-include), runspublish-guard.sh(forbidden paths/strings, gitleaks, optional strict report-tier), and pushes viapublish-mirror.shtoMIRROR_REPOusing a scoped GitHub App token. Prereleases and non-latest stable tags mirror releases only without rolling back the mirror default branch; release tags are fetched as data and pinned to the release run SHA.Ships
backfill-releases.shfor one-shot historical release migration (dry-run default,BINARY_KEEPbinary window, guard + SHA256 checks) and wires hermetic CI harnesses inbuild.ymlfor guard, publisher, workflow step bodies, and backfill (including mutation runs).CLI (0.10.26):
loginreuses a backend-valid stored session (exit 0, no device flow) with--forceto re-auth; fixes per-env profile lookup for mixed-case keys (Devvsdev). RFC-0076: canonical$TRACEBLOC_ENVwith legacy$CLIENT_ENVfallback inapi.ResolveEnv, doctor cluster stage read, and updated help/golden strings. SharedclassifyWhoAmIErrorfor login andauth status --check.Reviewed by Cursor Bugbot for commit 01ec0c7. Bugbot is set up for automated code reviews on this repo. Configure here.