Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ jobs:
shellcheck --shell=bash --severity=error scripts/tests/pin-version-verify.sh
bash -n scripts/check-pin-version.sh
bash -n scripts/tests/pin-version-verify.sh
shellcheck --shell=bash --severity=warning scripts/publish-guard.sh
shellcheck --shell=bash --severity=warning scripts/publish-mirror.sh
shellcheck --shell=bash --severity=error scripts/tests/publish-guard-verify.sh
shellcheck --shell=bash --severity=error scripts/tests/publish-mirror-verify.sh
shellcheck --shell=bash --severity=error scripts/tests/mirror-publish-workflow-verify.sh
bash -n scripts/tests/mirror-publish-workflow-verify.sh
bash -n scripts/publish-guard.sh
bash -n scripts/publish-mirror.sh
shellcheck --shell=bash --severity=warning scripts/backfill-releases.sh
shellcheck --shell=bash --severity=warning scripts/tests/backfill-releases-verify.sh
bash -n scripts/backfill-releases.sh
bash -n scripts/tests/backfill-releases-verify.sh
# format.sh's own fail-closed properties. Formatters are stubbed, so this is
# hermetic and needs no Go toolchain — which is why it lives in this job
# rather than Lint. It exists because the first cut of format.sh reported
Expand Down Expand Up @@ -127,6 +139,35 @@ jobs:
# pin-version-drift.yml, which must never gate a PR (backend#2704 / #1009).
- name: Pin-version watcher harness (drift reddens / fail-closed)
run: bash scripts/tests/pin-version-verify.sh
# The mirror-publish guard (scripts/publish-guard.sh) and publisher
# (scripts/publish-mirror.sh): each guard reddens on the thing it claims
# to catch (a forbidden path, a forbidden string, a missing scanner), an
# empty or unreadable list is "could not tell", and the publisher refuses
# an unset or self-pointing mirror. gitleaks is a PATH shim here, so this
# is hermetic; the workflow installs the real, pinned binary.
- name: Mirror-publish guard harness (refusals named / fail-closed)
run: bash scripts/tests/publish-guard-verify.sh
- name: Mirror-publish publisher harness (target / tree / release)
run: bash scripts/tests/publish-mirror-verify.sh
# The decisions mirror-publish.yml takes in its own step bodies — a
# prerelease keeps the mirror's default branch, the release tag is fetched
# as data only at the expected commit, no checkout takes an untrusted
# ref, a publisher refusal reaches the step log. The step bodies are read
# out of the YAML and executed with `gh` shimmed, so this is hermetic.
- name: Mirror-publish workflow harness (step bodies / shape / mutations)
run: bash scripts/tests/mirror-publish-workflow-verify.sh
# The one-shot historical backfill (scripts/backfill-releases.sh): dry-run
# writes nothing, --apply makes exactly the expected writes and a second
# --apply none, binaries stop at the BINARY_KEEP boundary, a binary that
# disagrees with SHA256SUMS or a forbidden string in a body refuses by
# name, a failed read is could-not-tell. `gh` is a recording fake serving
# fixtures, so this is hermetic. The second step breaks one rule per copy
# of the script and demands the same suite go red — a rule the suite
# cannot see reddens the build.
- name: Release-backfill harness (zero-write dry-run / idempotent / fail-closed)
run: bash scripts/tests/backfill-releases-verify.sh
- name: Release-backfill harness — mutations (every anchored rule is load-bearing)
run: bash scripts/tests/backfill-releases-verify.sh --mutations

test:
timeout-minutes: 15
Expand Down
452 changes: 452 additions & 0 deletions .github/workflows/mirror-publish.yml

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions .publish-forbidden
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# .publish-forbidden — what must never reach the public mirror, even if the
# allowlist (.publish-include) let it through by mistake.
#
# Read by scripts/publish-guard.sh. Four sections; `#` starts a comment. A
# header the guard does not know, a needle listed in both string tiers, or an
# empty [strings-refuse] is refused as "could not tell" (exit 2).
#
# [paths] gitignore-style names. A pattern containing `/` is
# anchored to the staged root; one without matches ANY path
# component; a trailing `/` means "as a directory".
# [strings-refuse] extended regexes, matched case-insensitively against
# every staged TEXT file. A hit REFUSES the publish and
# names the file and line (never the matched text).
# [strings-report] same syntax. Hits are COUNTED and printed (per-needle
# totals, ten most-hit files) but do not refuse — unless
# the guard runs with --strict, which promotes this tier
# to refusal. A needle moves up to [strings-refuse] the
# day it is decided the mirror must never carry it.
# [allow] exact tokens removed from a line before a needle is
# re-tested, so a line is spared only when the allowed
# token was the whole reason it hit.
#
# This file is ONE list read by both the guard and its tests; the tests write
# their own inputs and never iterate this file to check itself.

[paths]
tests/
scripts/tests/
ci/
.github/
docs/rfcs/
docs/migration-tools/
CLAUDE.md
STYLE.md
Makefile
.cursor/
*.go
go.mod
go.sum
__pycache__
.DS_Store
.env*
*.pem
*.key
kubeconfig*

[strings-refuse]
# Mailboxes (the public support address is spared under [allow]).
[A-Za-z0-9._%+-]+@tracebloc\.io
# AWS account identifiers and ARNs.
arn:aws:
[0-9]{12}\.dkr\.ecr\.
#
# CUSTOMER AND TENANT IDENTIFIERS ARE DELIBERATELY NOT LISTED HERE. This file
# is public, and a list of customer names would itself be the disclosure the
# scan exists to prevent. Those needles are supplied privately at publish time:
# the workflow writes the PUBLISH_FORBIDDEN_TENANTS secret (one needle per line,
# same regex syntax) to a file and passes it as --extra-forbidden; they join
# this tier. The guard refuses to run the scan when that list is missing or
# empty.

[strings-report]
# Internal tracker and RFC identifiers — a reader of the mirror cannot open
# them. Counted until the decision to strip them from the deliverable (or to
# accept them) is taken; --strict refuses them.
backend#
rfcs#
RFC-0
RFC-BACKEND
e2e-test-agent#
tracebloc/backend
# Non-production tracebloc hosts; same decision pending.
dev-api\.tracebloc\.io
stg-api\.tracebloc\.io
dev\.tracebloc\.io
stg\.tracebloc\.io

[allow]
support@tracebloc\.io
16 changes: 16 additions & 0 deletions .publish-include
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# .publish-include — what the public mirror of this repo MAY carry.
#
# Read by scripts/publish-guard.sh. One glob per line; `#` starts a comment.
# `*` and `?` do not cross `/`, `**` does; a leading `!` takes matching files
# back out. Only tracked files are considered. Anything not matched here is
# excluded by construction — .publish-forbidden is the second lock.
#
# The mirror is README + releases. The binaries, SHA256SUMS, signatures and
# the two installers travel as RELEASE ASSETS (copied from this repo's release
# by the publish workflow and scanned by the same guard), never as tree files.
# No Go source, no Makefile, no workflows: the forbidden list refuses each of
# those by name should a line here ever widen.
README.md
LICENSE
# The user docs README links to. One level only: docs/rfcs/ stays home.
docs/*.md
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.10.25
0.10.26
33 changes: 26 additions & 7 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,22 @@ import (
"time"
)

// Backend environments (mirror CLIENT_ENV).
// Backend environments (mirror the stage env var — $TRACEBLOC_ENV, legacy $CLIENT_ENV).
const (
EnvDev = "dev"
EnvStg = "stg"
EnvProd = "prod"
)

// Stage-selecting environment variables (RFC-0076 settings-naming, backend#3391).
// TRACEBLOC_ENV is the canonical name; CLIENT_ENV is the legacy alias, read as a
// fallback so existing installs and $CLIENT_ENV exports keep working. Alias-first:
// read new-or-old, never break a deployment. Legacy alias remove_by: 2026-12-31.
const (
StageEnvVar = "TRACEBLOC_ENV"
LegacyStageEnvVar = "CLIENT_ENV"
)

const defaultTimeout = 30 * time.Second

// ── User-Agent: minimum-CLI-version handshake (RFC-0001 §13 / §14 R11 / C.1) ──
Expand Down Expand Up @@ -80,9 +89,9 @@ func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error)
return t.base.RoundTrip(req)
}

// BaseURL maps a CLIENT_ENV value to the backend base URL — kept in lock-step
// with the installer's `_backend_url` and client-runtime's CLIENT_ENV→backend
// mapping. Unknown / empty → prod.
// BaseURL maps a stage value to the backend base URL — kept in lock-step with the
// installer's `_backend_url` and client-runtime's stage→backend mapping. Unknown /
// empty → prod.
func BaseURL(env string) string {
switch strings.ToLower(env) {
case EnvDev:
Expand All @@ -94,18 +103,28 @@ func BaseURL(env string) string {
}
}

// ResolveEnv picks the backend env: an explicit value (a --env flag) wins,
// then $CLIENT_ENV, then prod.
// ResolveEnv picks the backend env: an explicit value (a --env flag) wins, then
// the stage env var — canonical $TRACEBLOC_ENV, else legacy $CLIENT_ENV — then prod.
func ResolveEnv(explicit string) string {
if explicit != "" {
return strings.ToLower(explicit)
}
if e := os.Getenv("CLIENT_ENV"); e != "" {
if e := stageFromEnv(); e != "" {
return strings.ToLower(e)
}
return EnvProd
}

// stageFromEnv reads the deploy stage from the process environment, preferring the
// canonical TRACEBLOC_ENV over the legacy CLIENT_ENV alias (remove_by 2026-12-31).
// The single reader of both names, so the alias precedence lives in one place.
func stageFromEnv() string {
if e := os.Getenv(StageEnvVar); e != "" {
return e
}
return os.Getenv(LegacyStageEnvVar)
}

// IsKnownEnv reports whether env is one of the recognized backends (dev/stg/prod,
// case-insensitively). Callers that let a human PICK the env (e.g. `login`) use it
// to reject a typo up front — BaseURL deliberately falls unknown values back to
Expand Down
25 changes: 24 additions & 1 deletion internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,42 @@ func TestBaseURL(t *testing.T) {
}

func TestResolveEnv(t *testing.T) {
// Isolate both stage vars: the canonical name and the legacy alias.
t.Setenv("TRACEBLOC_ENV", "")
t.Setenv("CLIENT_ENV", "stg")
if got := ResolveEnv("dev"); got != "dev" {
t.Errorf("explicit should win: got %q", got)
}
if got := ResolveEnv(""); got != "stg" {
t.Errorf("CLIENT_ENV should be used: got %q", got)
t.Errorf("legacy $CLIENT_ENV should be used as the fallback: got %q", got)
}
t.Setenv("CLIENT_ENV", "")
if got := ResolveEnv(""); got != "prod" {
t.Errorf("default should be prod: got %q", got)
}
}

// TestResolveEnvStageAlias pins the RFC-0076 alias precedence (backend#3391): the
// canonical $TRACEBLOC_ENV is preferred, the legacy $CLIENT_ENV is read only as a
// fallback, and an explicit --env still beats both.
func TestResolveEnvStageAlias(t *testing.T) {
// Canonical alone is honoured.
t.Setenv("TRACEBLOC_ENV", "dev")
t.Setenv("CLIENT_ENV", "")
if got := ResolveEnv(""); got != "dev" {
t.Errorf("canonical $TRACEBLOC_ENV should be used: got %q", got)
}
// Canonical wins over the legacy alias when both are set.
t.Setenv("CLIENT_ENV", "prod")
if got := ResolveEnv(""); got != "dev" {
t.Errorf("canonical $TRACEBLOC_ENV should beat legacy $CLIENT_ENV: got %q", got)
}
// Explicit --env still wins over both.
if got := ResolveEnv("stg"); got != "stg" {
t.Errorf("explicit --env should beat the environment: got %q", got)
}
}

func TestIsKnownEnv(t *testing.T) {
known := []string{"dev", "stg", "prod", "DEV", "Prod"} // case-insensitive
for _, env := range known {
Expand Down
Loading
Loading