Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
7a3ada9
chore(studio): sync Constructor Studio config
Aug 7, 2026
1257fbe
feat(gitops): add the test-stand environment, CI emulation and deploy…
Aug 9, 2026
6071508
ci(stand): deploy, seed and smoke the published chart on the test stand
Aug 9, 2026
410427a
test(stand): add the post-deploy smoke gate for a deployed stand
Aug 9, 2026
6d043a0
ci(stand): do not inherit every repository secret into the deploy
Aug 9, 2026
0aca4e7
fix(stand): redact underscore-joined secret keys, and grant the cross…
Aug 9, 2026
ddfb903
ci(stand): let a dispatch run deploy and seed without the smoke crede…
Aug 10, 2026
edbcaef
docs(stand): say why the branch allow-list is the control, and that t…
Aug 10, 2026
78d1225
feat(gitops): re-sync the test-stand environment to the redeployed stand
Aug 10, 2026
54f3a14
fix(stand): recover the seed manifest from an interleaved log, and re…
Aug 10, 2026
f281958
fix(gitops): stop the persons-seed CronJob competing with the seeded …
Aug 10, 2026
6758a88
ci(stand): allow a dispatched reseed to override the foreign-rows guard
Aug 10, 2026
267538d
fix(seed): read the dev-lead address from the realm the stand applies
Aug 10, 2026
128ef21
ci(stand): drop the rehearsal harness, and let a dispatch take the ne…
Aug 10, 2026
4ffaf74
fix(stand): resolve the newest chart from the registry, not from a br…
Aug 10, 2026
25d41aa
fix(stand): seed a window the API can query, and let the caller rebui…
Aug 10, 2026
f894136
fix(tests/stand): skip an optional fixture instead of aborting, and s…
Aug 10, 2026
9240ddf
ci(stand): stop publishing cluster output, tell the truth in the summ…
Aug 11, 2026
fd6d7ae
ci(stand): deploy through `make deploy`, and let the environment decl…
Aug 11, 2026
0b39add
feat(gitops): an executable rebuild for the disposable stand, that dr…
Aug 11, 2026
5c483e7
fix(gitops): give each database client its own image, and stop a stra…
Aug 11, 2026
fa15e1d
fix(gitops): hand make the context the rebuild already validated
Aug 11, 2026
8ed5bfe
fix(gitops): ask the deploy's guards before the wipe, not between the…
Aug 11, 2026
39a1f5d
refactor(stand-ci): fold the stand tooling into gitops, one spelling …
Aug 11, 2026
32173d7
ci(stand): downgrades have no override — recovery is recreate, never …
Aug 11, 2026
4233607
ci(stand): let a manual dispatch omit the version and take the regist…
Aug 11, 2026
439df8c
feat(seed): let a stand set the persona password via INSIGHT_SEED_PER…
Aug 11, 2026
5c856e8
ci(stand): TEMPORARY debug trigger for the deploy pipeline — delete b…
Aug 11, 2026
86097a9
ci(stand): TEMPORARY branch exception in the main-only guard for the …
Aug 11, 2026
9db43bd
ci(stand): debug run marker 3
Aug 11, 2026
2f69c07
ci(stand): TEMPORARY manifest-capture fallback for the pre-merge debu…
Aug 11, 2026
8daa2de
ci(stand): fix the TEMPORARY manifest fallback — stop at the document…
Aug 11, 2026
dbdedfc
ci(stand): TEMPORARY fallback reads the completed Job's log at rest
Aug 11, 2026
b0b3ecd
ci(stand): TEMPORARY fallback filters interleaved stderr log lines
Aug 11, 2026
51ba45c
ci(stand): debug run marker 8
Aug 11, 2026
6a6bf38
test(stand): xfail the metric non-null check — seeded observations bi…
Aug 12, 2026
9250bc7
ci(stand): debug run marker 9 — full rerun after rebase on main
Aug 12, 2026
1601d77
docs(stand): comment diet — constraints stay, narration goes
Aug 12, 2026
4cfbe04
ci(stand): debug run marker 10 — seed + smoke the recreated stand
Aug 12, 2026
be32a18
docs+seed(stand): retire the dead login-mode machinery from the docs;…
Aug 12, 2026
47dc28d
ci(stand): the deploy leaves the build's concurrency slot; TEMPORARY …
Aug 12, 2026
717bd6b
security(stand): enumerate the CI deployer's grants instead of bindin…
Aug 12, 2026
0ac7cb9
Merge remote-tracking branch 'origin/main' into test-stand-ci
Aug 12, 2026
ee14b43
test(stand): give the /auth/me module its quality vector — main's gat…
Aug 12, 2026
4209972
Merge remote-tracking branch 'origin/main' into test-stand-ci
Aug 12, 2026
3593ad0
ci(stand): TEMPORARY debug trigger returns for a pre-merge CI run — d…
Aug 12, 2026
2738b62
ci(stand): TEMPORARY — debug run 12 executes the full stand suite
Aug 12, 2026
7fe2860
fix(stand): the blob rule no longer eats file paths
Aug 12, 2026
44fadc3
Revert "ci(stand): TEMPORARY — debug run 12 executes the full stand s…
Aug 12, 2026
8ec2e27
Revert "ci(stand): TEMPORARY debug trigger returns for a pre-merge CI…
Aug 12, 2026
c376a1e
docs(stand): move the CI credentials runbook next to the stand it doc…
Aug 12, 2026
6eac4bd
docs(stand): condense INFRA.md to a scannable reference (1193 -> 291 …
Aug 12, 2026
39aacec
docs(stand): condense README to the deploy walkthrough (484 -> 285 li…
Aug 12, 2026
81da0de
docs(stand): tighten the RBAC manifest comments
Aug 12, 2026
0475793
Merge remote-tracking branch 'origin/main' into test-stand-ci
Aug 12, 2026
fce64e1
chore(stand): clear the validation-sweep warnings
Aug 12, 2026
bc764b8
fix(stand): seed to a resolved stand — run the identity projection an…
Aug 12, 2026
8da7f87
ci(stand): TEMPORARY debug trigger for a pre-merge CI run — delete be…
Aug 12, 2026
51ad3b8
test(stand): un-xfail test_metric_results_200 — #2447 verified on the…
Aug 12, 2026
063e677
Revert "ci(stand): TEMPORARY debug trigger for a pre-merge CI run — d…
Aug 12, 2026
91346c3
Merge remote-tracking branch 'origin/main' into test-stand-ci
Aug 12, 2026
9adffd0
Reapply "ci(stand): TEMPORARY debug trigger for a pre-merge CI run — …
Aug 12, 2026
e6b7262
Revert "Reapply "ci(stand): TEMPORARY debug trigger for a pre-merge C…
Aug 12, 2026
f28d1f2
Merge remote-tracking branch 'origin/main' into test-stand-ci
Aug 13, 2026
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
2 changes: 1 addition & 1 deletion .cf-studio/config/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# config — User Configuration
# config -- User Configuration

This directory contains **user-editable** configuration files.

Expand Down
1 change: 1 addition & 0 deletions .cf-studio/config/core.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ version = "v1.2.1"
source = "github:constructorfabric/studio-kit-sdlc"
install_mode = "copy"
tracking = "tracked"
tool_risk_fingerprint = "3bdcd30d3029ad80e74c76eb0b57d80ad617a51191eb9d9307b2921c65f5dc16"

[kits.sdlc.resources]
[kits.sdlc.resources.adr_template]
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/deploy-test-stand-on-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Fires after a main build finishes, OUTSIDE its concurrency slot: a slow
# stand deploy must never queue the next merge's image build or chart
# publish behind it (deploy-test-stand.yml has its own coalescing group).
# The version is resolved from the OCI registry's newest published tag —
# which IS the chart that run just published; the downgrade guard refuses
# anything older than what is deployed.
name: Deploy test stand after publish
on:
workflow_run:
workflows: ["Build & Push Container Images"]
types: [completed]
branches: [main]

permissions:
contents: read

jobs:
deploy:
# The whole run must be green: publish-chart sits at its end, so a red
# run either never published or published amid other failures — neither
# is a state to install.
if: github.event.workflow_run.conclusion == 'success'
permissions:
contents: read
uses: ./.github/workflows/deploy-test-stand.yml
562 changes: 562 additions & 0 deletions .github/workflows/deploy-test-stand.yml

Large diffs are not rendered by default.

179 changes: 179 additions & 0 deletions .github/workflows/scripts/redact-stand-log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Mask credential-shaped text out of stand output before it reaches a public log.

This repository is PUBLIC, and so is every line a workflow prints; cluster and
seeder output goes through here rather than straight to the console.

**Fail closed, per line.** A line this filter cannot prove it cleaned becomes
`LINE_MARKER`, and the pass at the end of `_clean` re-scans the *result* rather
than trusting the substitutions; an exception aborts the stream, because a
truncated diagnostic is recoverable and a published secret is not.

No structure awareness: a secret split across lines, or shaped like an ordinary
word, survives — the workflow's own no-print discipline covers the rest.

Usage:
<producer> | redact-stand-log.py # stdin -> stdout, line at a time
redact-stand-log.py FILE [FILE ...] # named files -> stdout
redact-stand-log.py --max-line 400 FILE # tighter truncation

Exit 0 when the stream was cleaned, 1 when it aborted — pipe under `pipefail`.
"""

from __future__ import annotations

import hashlib
import re
import sys
from collections.abc import Iterable, Iterator
from pathlib import Path

#: What an unrecoverable line becomes. Deliberately loud: a reader must be able
#: to tell "this line was removed" from "nothing was logged here".
LINE_MARKER = "[line withheld by CI — redaction could not be verified]"

#: Default ceiling on a single emitted line. Long enough for a helm error or a
#: Rust panic with a backtrace frame, short enough that a blob cannot ride out.
DEFAULT_MAX_LINE = 1000

#: Placeholder standing in for an image digest while the long-blob rule runs.
#: Restored verbatim afterwards — see `_clean`.
_DIGEST_SLOT = "\x00digest{}\x00"

_URL_CREDENTIALS = re.compile(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.\-]*://)[^\s/@:]+:[^\s/@]+@")
_EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_JWT = re.compile(r"\beyJ[A-Za-z0-9_\-]{4,}\.[A-Za-z0-9_\-]{4,}(?:\.[A-Za-z0-9_\-]+)?")
_BEARER = re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=\-]{8,}")
_SESSION_COOKIE = re.compile(r"(?i)(__Host-sid|__Secure-sid|sid)=[^\s;,\"']{8,}")
_PRIVATE_KEY_HEADER = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")

#: Matched case-insensitively against the token left of a `:`/`=`. Boundary is
#: `(?<![A-Za-z0-9])`, not `\b`, which misses `MARIADB_PASSWORD=` — load-bearing.
_SECRET_KEY = re.compile(
r"(?i)(?<![A-Za-z0-9])([A-Za-z0-9_.\-]*(?:"
r"password|passwd|pwd|secret|secret[_\-]?key|client[_\-]?secret|"
r"token|access[_\-]?token|refresh[_\-]?token|id[_\-]?token|bearer[_\-]?token|"
r"api[_\-]?key|private[_\-]?key|signing[_\-]?key|"
r"client-key-data|client-certificate-data|certificate-authority-data"
r"))([\"']?\s*[:=]\s*)(?!\s*$)\S+"
)

#: An image digest: exempt, because it answers "which image failed to pull".
_DIGEST = re.compile(r"\bsha(?:256|512):[0-9a-fA-F]{32,128}\b")

#: The catch-all. 40 chars is above every object name and image tag in this
#: stack, below every key and token; alphabet covers base64 and base64url.
_LONG_BLOB = re.compile(r"(?<![A-Za-z0-9+/_=-])[A-Za-z0-9+/_-]{40,}={0,2}(?![A-Za-z0-9+/_=-])")

_IPV4 = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")


def _blob_mask(match: re.Match[str]) -> str:
"""Mask unless the token provably is not base64 of either variant.

Standard base64 never contains `_`/`-`; base64url never contains `/`/`+`.
A token mixing the two exclusive sets (a pytest path like
tests/stand/ui/test_logged_out_access_refused) is therefore not a blob.
"""
token = match.group(0)
if ("/" in token or "+" in token) and ("-" in token or "_" in token):
return token
return "[blob redacted]"


def _blob_survivor(text: str) -> bool:
"""True when a maskable (non-exempt) blob is still present in `text`."""
return any(_blob_mask(m) != m.group(0) for m in _LONG_BLOB.finditer(_DIGEST.sub("", text)))


def _email_slot(match: re.Match[str]) -> str:
"""A stable one-way handle for one address.

Six hex characters: enough that two personas in the same log are told apart,
far too few to attack, and short enough to keep a table readable.
"""
digest = hashlib.sha256(match.group(0).lower().encode("utf-8")).hexdigest()[:6]
return f"[email:{digest}]"


def _clean(line: str, *, max_line: int) -> str:
"""Return `line` with every credential-shaped run replaced.

Order is load-bearing. URLs go first so a DSN's password is masked as a URL
credential rather than half-caught by the email rule; digests are parked
before the long-blob sweep and restored after it; the verification pass runs
last, over the bytes that are actually about to be printed.
"""
if _PRIVATE_KEY_HEADER.search(line):
# The header names the key type and nothing else useful, and the body
# is caught by the blob rule. A PEM in a CI log is never a diagnostic.
return "[private key material withheld by CI]"

out = _URL_CREDENTIALS.sub(r"\g<scheme>[credentials redacted]@", line)
out = _JWT.sub("[jwt redacted]", out)
out = _BEARER.sub(r"\1 [redacted]", out)
out = _SESSION_COOKIE.sub(r"\1=[redacted]", out)
out = _SECRET_KEY.sub(r"\1\2[redacted]", out)
out = _EMAIL.sub(_email_slot, out)

digests: list[str] = []

def _park(match: re.Match[str]) -> str:
digests.append(match.group(0))
return _DIGEST_SLOT.format(len(digests) - 1)

out = _DIGEST.sub(_park, out)
out = _LONG_BLOB.sub(_blob_mask, out)
for index, digest in enumerate(digests):
out = out.replace(_DIGEST_SLOT.format(index), digest)

out = _IPV4.sub("[ip redacted]", out)

if len(out) > max_line:
out = out[:max_line] + f" …[truncated at {max_line} chars by CI]"

# The result, not the input: anything credential-shaped that survived means
# a rule has a hole. Digests stripped first so an exempt one cannot trip it.
if _EMAIL.search(out) or _JWT.search(out) or _PRIVATE_KEY_HEADER.search(out) or _blob_survivor(out):
return LINE_MARKER
return out
Comment on lines +99 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Truncate the input before the rules run, not after.

Line 132 applies max_line to the output. Every regex at lines 107-130 therefore scans the full input line first. A pod log or a helm error can carry a single line of many megabytes, and _SECRET_KEY at lines 52-59 backtracks over [A-Za-z0-9_.\-]* at each start position, so scan cost grows faster than line length. The step then stalls inside the redactor with no output.

Cap the input first. The masking result stays the same for anything that fits, and the truncation notice still tells the reader that content was dropped.

♻️ Proposed change
     if _PRIVATE_KEY_HEADER.search(line):
         # The header names the key type and nothing else useful, and the body
         # is caught by the blob rule. A PEM in a CI log is never a diagnostic.
         return "[private key material withheld by CI]"
 
-    out = _URL_CREDENTIALS.sub(r"\g<scheme>[credentials redacted]@", line)
+    truncated = len(line) > max_line
+    out = _URL_CREDENTIALS.sub(r"\g<scheme>[credentials redacted]@", line[:max_line])
@@
-    if len(out) > max_line:
+    if truncated or len(out) > max_line:
         out = out[:max_line] + f" …[truncated at {max_line} chars by CI]"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/scripts/redact-stand-log.py around lines 99 - 139, Update
_clean to cap the input line to max_line before running any credential-masking
regexes, while retaining the existing output truncation notice when content was
dropped. Ensure lines within the limit keep their current masking behavior and
avoid scanning the full oversized input.



def _stream(lines: Iterable[str], *, max_line: int) -> Iterator[str]:
for line in lines:
yield _clean(line.rstrip("\n"), max_line=max_line)


def main(argv: list[str]) -> int:
args = argv[1:]
max_line = DEFAULT_MAX_LINE
if args[:1] == ["--max-line"]:
if len(args) < 2 or not args[1].isdigit() or int(args[1]) < 80:
print("--max-line needs a whole number of at least 80", file=sys.stderr) # noqa: T201
return 2
max_line = int(args[1])
args = args[2:]

try:
if not args:
for cleaned in _stream(sys.stdin, max_line=max_line):
print(cleaned, flush=True) # noqa: T201
return 0
for path in args:
with Path(path).open(encoding="utf-8", errors="replace") as handle:
for cleaned in _stream(handle, max_line=max_line):
print(cleaned, flush=True) # noqa: T201
except BrokenPipeError:
# The consumer went away (a `head`, or a cancelled step). Not a
# redaction failure, and not worth a red X.
return 0
except Exception as exc: # noqa: BLE001 — any failure means "stop printing"
# Whatever was emitted before this point was cleaned; what follows was
# not, so nothing follows.
print(f"::error::redaction aborted, output truncated: {type(exc).__name__}: {exc}", file=sys.stderr) # noqa: T201
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
<!-- @cf:root-agents -->
```toml
cf-studio-path = ".cf-studio"
```

ALWAYS resolve and enforce prerequisites of skills/workflows/commands BEFORE applying user intent.
<!-- /@cf:root-agents -->

@AGENTS.md
Loading
Loading