forked from molecule-ai/molecule-core
commit
a3864eaf3d
134
.github/scripts/lint_secret_pattern_drift.py
vendored
Normal file
134
.github/scripts/lint_secret_pattern_drift.py
vendored
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lint SECRET_PATTERNS drift across known consumers of molecule-core's canonical.
|
||||||
|
|
||||||
|
The canonical SECRET_PATTERNS array in
|
||||||
|
.github/workflows/secret-scan.yml is mirrored by every other side
|
||||||
|
that scans for credentials: the workspace-runtime's bundled
|
||||||
|
pre-commit hook, the molecule-controlplane inlined copy, etc. The
|
||||||
|
mirror is enforced socially today — when someone adds a new pattern
|
||||||
|
to canonical (e.g. the sk-cp- MiniMax token after F1088), the other
|
||||||
|
sides are supposed to be updated in lockstep.
|
||||||
|
|
||||||
|
This script automates the check. Diffs the canonical's pattern set
|
||||||
|
against each known public consumer and exits non-zero on any
|
||||||
|
mismatch. Wired into a daily cron + on-push gate via
|
||||||
|
.github/workflows/secret-pattern-drift.yml.
|
||||||
|
|
||||||
|
Private-repo consumers (currently molecule-controlplane's inlined
|
||||||
|
copy) are out of scope here because the molecule-core workflow's
|
||||||
|
GITHUB_TOKEN can't read other private repos in the org. They're
|
||||||
|
expected to self-monitor via their own copy of this script — not a
|
||||||
|
hard barrier, just a future expansion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CANONICAL_FILE = Path(".github/workflows/secret-scan.yml")
|
||||||
|
|
||||||
|
# Public consumer mirrors. Each entry is (label, raw_url) — raw_url
|
||||||
|
# points at the file's RAW content on the consumer's default branch
|
||||||
|
# (or staging where applicable). Add an entry here when a new public
|
||||||
|
# repo starts shipping its own SECRET_PATTERNS array.
|
||||||
|
CONSUMERS: list[tuple[str, str]] = [
|
||||||
|
(
|
||||||
|
"molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh",
|
||||||
|
"https://raw.githubusercontent.com/Molecule-AI/molecule-ai-workspace-runtime/main/molecule_runtime/scripts/pre-commit-checks.sh",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Matches the SECRET_PATTERNS=( ... ) array in either yaml-indented
|
||||||
|
# (the canonical workflow's `run:` block) or shell-flat (runtime
|
||||||
|
# hook) format. Patterns inside are single-quoted Bash strings; we
|
||||||
|
# pull each via _PATTERN_RE.
|
||||||
|
#
|
||||||
|
# Closing `)` is anchored to the start of a line (possibly indented)
|
||||||
|
# because pattern comments like `# GitHub PAT (classic)` contain
|
||||||
|
# their own `)` mid-line — a non-anchored regex would match through
|
||||||
|
# the comment's paren and capture only the first pattern.
|
||||||
|
_ARRAY_RE = re.compile(r"SECRET_PATTERNS=\((.*?)^\s*\)", re.DOTALL | re.MULTILINE)
|
||||||
|
_PATTERN_RE = re.compile(r"'([^']+)'")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_patterns(content: str, source_label: str) -> list[str]:
|
||||||
|
"""Pull the SECRET_PATTERNS list out of either format. Raises if missing."""
|
||||||
|
m = _ARRAY_RE.search(content)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit(f"::error::{source_label}: SECRET_PATTERNS=(...) array not found")
|
||||||
|
return _PATTERN_RE.findall(m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str) -> str:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": "secret-pattern-drift-lint/1"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.read().decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def diff_patterns(canonical: list[str], consumer: list[str]) -> tuple[list[str], list[str]]:
|
||||||
|
"""Return (missing_from_consumer, extra_in_consumer) — both sorted."""
|
||||||
|
canonical_set = set(canonical)
|
||||||
|
consumer_set = set(consumer)
|
||||||
|
return (
|
||||||
|
sorted(canonical_set - consumer_set),
|
||||||
|
sorted(consumer_set - canonical_set),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not CANONICAL_FILE.exists():
|
||||||
|
print(f"::error::canonical not found at {CANONICAL_FILE}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
canonical = extract_patterns(CANONICAL_FILE.read_text(), str(CANONICAL_FILE))
|
||||||
|
print(f"canonical ({CANONICAL_FILE}): {len(canonical)} patterns")
|
||||||
|
|
||||||
|
drift = False
|
||||||
|
for label, url in CONSUMERS:
|
||||||
|
try:
|
||||||
|
content = fetch(url)
|
||||||
|
except Exception as e:
|
||||||
|
# Fetch failures are warnings, not errors. A consumer
|
||||||
|
# whose default branch was just renamed (or whose file
|
||||||
|
# moved) shouldn't fail the lint until someone updates
|
||||||
|
# the URL above. Real drift is the failure mode this
|
||||||
|
# gate exists to catch — fetch reliability isn't.
|
||||||
|
print(f"::warning::{label}: fetch failed ({e}) — skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
consumer = extract_patterns(content, label)
|
||||||
|
missing, extra = diff_patterns(canonical, consumer)
|
||||||
|
if not missing and not extra:
|
||||||
|
print(f" ✓ {label}: aligned ({len(consumer)} patterns)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
drift = True
|
||||||
|
print(f"::error::DRIFT in {label}:")
|
||||||
|
for p in missing:
|
||||||
|
print(f" - missing from consumer: {p!r}")
|
||||||
|
for p in extra:
|
||||||
|
print(f" - extra in consumer (not in canonical): {p!r}")
|
||||||
|
|
||||||
|
if drift:
|
||||||
|
print()
|
||||||
|
print("::error::SECRET_PATTERNS drift detected. Bring consumer(s) into")
|
||||||
|
print("alignment with the canonical SECRET_PATTERNS array in")
|
||||||
|
print(f"{CANONICAL_FILE} by adding the missing patterns and removing")
|
||||||
|
print("any extras. The two sides must stay byte-aligned on the pattern")
|
||||||
|
print("list — the runtime hook is the developer's local pre-commit,")
|
||||||
|
print("the canonical is the org-wide CI gate, divergence means a token")
|
||||||
|
print("can pass one but get rejected by the other.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("✓ All known consumers aligned with canonical SECRET_PATTERNS.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
114
.github/workflows/auto-promote-on-e2e.yml
vendored
Normal file
114
.github/workflows/auto-promote-on-e2e.yml
vendored
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
name: Auto-promote :latest on E2E green
|
||||||
|
|
||||||
|
# Retags `ghcr.io/molecule-ai/{platform,platform-tenant}:staging-<sha>`
|
||||||
|
# → `:latest` whenever E2E Staging SaaS passes for a `main` push.
|
||||||
|
#
|
||||||
|
# This is the doc-aligned alternative to the (deferred) Phase 2 canary
|
||||||
|
# fleet — staging E2E catches ~90% of what canary would catch at 0%
|
||||||
|
# ongoing infra cost. See `molecule-controlplane/docs/canary-tenants.md`
|
||||||
|
# section "Do we actually need canary right now?" — recommended
|
||||||
|
# sequencing for the current scale (≤20 paying tenants).
|
||||||
|
#
|
||||||
|
# Why a separate workflow rather than folding into e2e-staging-saas.yml:
|
||||||
|
# - Keeps test concerns separate from release concerns.
|
||||||
|
# - Disabling promote (e.g. during an incident) is one toggle, not an
|
||||||
|
# edit to the long E2E workflow file.
|
||||||
|
# - When Phase 2 canary work eventually lands, the canary path can
|
||||||
|
# replace this file's trigger without touching the E2E workflow.
|
||||||
|
#
|
||||||
|
# Why trigger on `main` only:
|
||||||
|
# - `:latest` is what prod tenants pull. We only want SHAs that have
|
||||||
|
# reached `main` (via auto-promote-staging) to advance `:latest`.
|
||||||
|
# - Triggering on staging would let a staging-only revert advance
|
||||||
|
# `:latest` to a SHA that never reaches `main`, breaking the
|
||||||
|
# "production runs what's on `main`" invariant.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ['E2E Staging SaaS (full lifecycle)']
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sha:
|
||||||
|
description: 'Short sha to promote (override; defaults to upstream workflow_run head_sha)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/molecule-ai/platform
|
||||||
|
TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
promote:
|
||||||
|
# Skip if E2E failed — `:latest` stays on the prior known-good
|
||||||
|
# digest. Manual dispatch always proceeds (the operator already
|
||||||
|
# decided to promote).
|
||||||
|
if: |
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Compute short sha
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -n "${{ github.event.inputs.sha }}" ]; then
|
||||||
|
FULL="${{ github.event.inputs.sha }}"
|
||||||
|
else
|
||||||
|
FULL="${{ github.event.workflow_run.head_sha }}"
|
||||||
|
fi
|
||||||
|
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "full=${FULL}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: imjasonh/setup-crane@v0.4
|
||||||
|
|
||||||
|
- name: GHCR login
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.GITHUB_TOKEN }}" | \
|
||||||
|
crane auth login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Verify :staging-<sha> exists for both images
|
||||||
|
# Better to fail fast with a clear message than to half-tag
|
||||||
|
# (platform retagged but platform-tenant missing → tenants pull
|
||||||
|
# a stale image).
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for img in "${IMAGE_NAME}" "${TENANT_IMAGE_NAME}"; do
|
||||||
|
tag="${img}:staging-${{ steps.sha.outputs.short }}"
|
||||||
|
if ! crane manifest "$tag" >/dev/null 2>&1; then
|
||||||
|
echo "::error::Missing tag: $tag"
|
||||||
|
echo "::error::publish-workspace-server-image must complete on this SHA before auto-promote-on-e2e can retag :latest."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ok: $tag exists"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Retag platform :staging-<sha> → :latest
|
||||||
|
run: |
|
||||||
|
crane tag "${IMAGE_NAME}:staging-${{ steps.sha.outputs.short }}" latest
|
||||||
|
|
||||||
|
- name: Retag tenant :staging-<sha> → :latest
|
||||||
|
run: |
|
||||||
|
crane tag "${TENANT_IMAGE_NAME}:staging-${{ steps.sha.outputs.short }}" latest
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "## E2E green → :latest promoted"
|
||||||
|
echo
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "- Trigger: manual dispatch"
|
||||||
|
else
|
||||||
|
echo "- Upstream E2E run: ${{ github.event.workflow_run.html_url }}"
|
||||||
|
fi
|
||||||
|
echo "- platform:staging-${{ steps.sha.outputs.short }} → :latest"
|
||||||
|
echo "- platform-tenant:staging-${{ steps.sha.outputs.short }} → :latest"
|
||||||
|
echo
|
||||||
|
echo "Tenant fleet auto-pulls within 5 min via IMAGE_AUTO_REFRESH=true."
|
||||||
|
echo "Force immediate fanout: dispatch redeploy-tenants-on-main.yml."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
149
.github/workflows/auto-sync-main-to-staging.yml
vendored
Normal file
149
.github/workflows/auto-sync-main-to-staging.yml
vendored
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
name: Auto-sync main → staging
|
||||||
|
|
||||||
|
# Reflects every push to `main` back onto `staging` so the
|
||||||
|
# staging-as-superset-of-main invariant holds.
|
||||||
|
#
|
||||||
|
# Background:
|
||||||
|
#
|
||||||
|
# `auto-promote-staging.yml` advances main via `git merge --ff-only`
|
||||||
|
# + `git push origin main` — that's a clean fast-forward, no merge
|
||||||
|
# commit. But manual merges of `staging → main` PRs through the
|
||||||
|
# GitHub UI / API create a merge commit on main that staging
|
||||||
|
# doesn't have. The next `staging → main` PR then evaluates as
|
||||||
|
# "BEHIND" because staging is missing that merge commit, requiring
|
||||||
|
# a manual `gh pr update-branch` round-trip.
|
||||||
|
#
|
||||||
|
# This happened twice on 2026-04-28 (PRs #2202, #2205, both manual
|
||||||
|
# bridges). Each time the bridge needed update-branch + a re-CI
|
||||||
|
# round before merging. Operationally annoying and avoidable.
|
||||||
|
#
|
||||||
|
# This workflow closes the gap automatically:
|
||||||
|
#
|
||||||
|
# 1. Push to main fires (regardless of source: auto-promote, UI
|
||||||
|
# merge, API merge, direct push).
|
||||||
|
# 2. Check whether main is already in staging's ancestry — if
|
||||||
|
# yes, no-op (auto-promote-staging already kept them in sync
|
||||||
|
# via fast-forward).
|
||||||
|
# 3. If not, try fast-forward staging to main first (works when
|
||||||
|
# staging hasn't diverged with its own commits).
|
||||||
|
# 4. If ff fails (staging has commits main doesn't — feature work
|
||||||
|
# in flight), do a real merge with a "chore: sync" commit so
|
||||||
|
# staging absorbs main's tip while keeping its own history.
|
||||||
|
# 5. Push staging.
|
||||||
|
#
|
||||||
|
# Loop safety:
|
||||||
|
#
|
||||||
|
# `GITHUB_TOKEN`-authored pushes do NOT trigger downstream workflow
|
||||||
|
# runs by default (GitHub Actions safety). So when this workflow
|
||||||
|
# pushes the synced staging, `auto-promote-staging.yml` is NOT
|
||||||
|
# triggered by that push. The next developer push to staging triggers
|
||||||
|
# auto-promote normally. No loop is even theoretically possible.
|
||||||
|
#
|
||||||
|
# Concurrency:
|
||||||
|
#
|
||||||
|
# Two pushes to main in quick succession (e.g., manual UI merge
|
||||||
|
# immediately followed by auto-promote-staging's ff-merge) would
|
||||||
|
# otherwise race two auto-sync runs against the same staging branch
|
||||||
|
# — second push fails non-fast-forward. The concurrency group
|
||||||
|
# serializes them so the second run sees the first's result.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: auto-sync-main-to-staging
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-staging:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout staging
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: staging
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Configure git author
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Check if staging already contains main
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git fetch origin main
|
||||||
|
if git merge-base --is-ancestor origin/main HEAD; then
|
||||||
|
echo "needs_sync=false" >> "$GITHUB_OUTPUT"
|
||||||
|
{
|
||||||
|
echo "## ✅ No-op"
|
||||||
|
echo
|
||||||
|
echo "staging already contains \`origin/main\` ($(git rev-parse --short=8 origin/main))."
|
||||||
|
echo "auto-promote-staging or a previous auto-sync run already kept them aligned."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "needs_sync=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::staging is missing main's tip — sync needed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Fast-forward staging to main
|
||||||
|
if: steps.check.outputs.needs_sync == 'true'
|
||||||
|
id: ff
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if git merge --ff-only origin/main; then
|
||||||
|
echo "did_ff=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::Fast-forwarded staging to origin/main"
|
||||||
|
else
|
||||||
|
echo "did_ff=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::ff failed — staging has its own commits; will create merge"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Merge main into staging (when ff fails)
|
||||||
|
if: steps.check.outputs.needs_sync == 'true' && steps.ff.outputs.did_ff != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# ff failed because staging has commits main doesn't — typical
|
||||||
|
# in-flight feature work. Create a merge commit so staging
|
||||||
|
# absorbs main's tip while keeping its own history.
|
||||||
|
if ! git merge --no-ff origin/main -m "chore: sync main → staging (auto)"; then
|
||||||
|
# Hygiene: leave the work tree clean before failing. Doesn't
|
||||||
|
# affect future runs (each gets a fresh checkout) but a
|
||||||
|
# half-merged tree is an unpleasant artifact to debug if
|
||||||
|
# anyone ever shells into the runner.
|
||||||
|
git merge --abort || true
|
||||||
|
{
|
||||||
|
echo "## ❌ Conflict"
|
||||||
|
echo
|
||||||
|
echo "Auto-merge \`main → staging\` failed with conflicts."
|
||||||
|
echo "A human needs to resolve manually:"
|
||||||
|
echo
|
||||||
|
echo " git checkout staging"
|
||||||
|
echo " git merge origin/main"
|
||||||
|
echo " # resolve, commit, push"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push staging
|
||||||
|
if: steps.check.outputs.needs_sync == 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git push origin staging
|
||||||
|
{
|
||||||
|
if [ "${{ steps.ff.outputs.did_ff }}" = "true" ]; then
|
||||||
|
echo "## ✅ staging fast-forwarded"
|
||||||
|
echo
|
||||||
|
echo "staging is now at \`$(git rev-parse --short=8 HEAD)\` (== origin/main)."
|
||||||
|
else
|
||||||
|
echo "## ✅ staging absorbed main"
|
||||||
|
echo
|
||||||
|
echo "staging is now at \`$(git rev-parse --short=8 HEAD)\` with a merge commit absorbing main's tip."
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
57
.github/workflows/secret-pattern-drift.yml
vendored
Normal file
57
.github/workflows/secret-pattern-drift.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
name: SECRET_PATTERNS drift lint
|
||||||
|
|
||||||
|
# Detects when the canonical SECRET_PATTERNS array in
|
||||||
|
# .github/workflows/secret-scan.yml diverges from known consumer
|
||||||
|
# mirrors (workspace-runtime's bundled pre-commit hook today; more
|
||||||
|
# can be added as the consumer set grows).
|
||||||
|
#
|
||||||
|
# Why this exists: every side that scans for credentials has its own
|
||||||
|
# copy of the pattern list. They drift — most recently the runtime
|
||||||
|
# hook lagged the canonical by one pattern (sk-cp- / MiniMax F1088),
|
||||||
|
# so a developer's local pre-commit would let a sk-cp- token through
|
||||||
|
# while the org-wide CI scan would refuse it. The cost of that drift
|
||||||
|
# is dev confusion + delayed feedback; the fix is automated detection.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - schedule: daily 05:00 UTC. Catches drift introduced by edits
|
||||||
|
# to a consumer copy that didn't update canonical here.
|
||||||
|
# - push to main/staging where the canonical or this lint changed:
|
||||||
|
# catches the inverse — canonical updated but consumers not yet
|
||||||
|
# bumped. The lint will fail the push; that's intentional, the
|
||||||
|
# person editing canonical is the right person to also update
|
||||||
|
# the consumer.
|
||||||
|
# - workflow_dispatch: ad-hoc operator runs.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 05:00 UTC = 22:00 PT / 01:00 ET. Quiet hours so a failure
|
||||||
|
# email lands when humans are starting their day, not
|
||||||
|
# interrupting it.
|
||||||
|
- cron: "0 5 * * *"
|
||||||
|
push:
|
||||||
|
branches: [main, staging]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/secret-scan.yml"
|
||||||
|
- ".github/workflows/secret-pattern-drift.yml"
|
||||||
|
- ".github/scripts/lint_secret_pattern_drift.py"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# GITHUB_TOKEN scoped to read-only. The lint only does git checkout
|
||||||
|
# + HTTPS GETs to public consumer files; no writes to anything.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Detect SECRET_PATTERNS drift
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Run drift lint
|
||||||
|
run: python3 .github/scripts/lint_secret_pattern_drift.py
|
||||||
19
.github/workflows/secret-scan.yml
vendored
19
.github/workflows/secret-scan.yml
vendored
@ -148,7 +148,13 @@ jobs:
|
|||||||
SELF=".github/workflows/secret-scan.yml"
|
SELF=".github/workflows/secret-scan.yml"
|
||||||
|
|
||||||
OFFENDING=""
|
OFFENDING=""
|
||||||
for f in $CHANGED; do
|
# `while IFS= read -r` (not `for f in $CHANGED`) so filenames
|
||||||
|
# containing whitespace don't word-split silently — a path
|
||||||
|
# with a space would otherwise produce two iterations on
|
||||||
|
# tokens that aren't real filenames, breaking the
|
||||||
|
# self-exclude + diff lookup.
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
[ "$f" = "$SELF" ] && continue
|
[ "$f" = "$SELF" ] && continue
|
||||||
if [ -n "$DIFF_RANGE" ]; then
|
if [ -n "$DIFF_RANGE" ]; then
|
||||||
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||||
@ -164,11 +170,18 @@ jobs:
|
|||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
done
|
done <<< "$CHANGED"
|
||||||
|
|
||||||
if [ -n "$OFFENDING" ]; then
|
if [ -n "$OFFENDING" ]; then
|
||||||
echo "::error::Credential-shaped strings detected in diff additions:"
|
echo "::error::Credential-shaped strings detected in diff additions:"
|
||||||
printf "$OFFENDING"
|
# `printf '%b' "$OFFENDING"` interprets backslash escapes
|
||||||
|
# (the literal `\n` we appended above becomes a newline)
|
||||||
|
# WITHOUT treating OFFENDING as a format string. Plain
|
||||||
|
# `printf "$OFFENDING"` is a format-string sink: a filename
|
||||||
|
# containing `%` would be interpreted as a conversion
|
||||||
|
# specifier, corrupting the error message (or printing
|
||||||
|
# `%(missing)` artifacts).
|
||||||
|
printf '%b' "$OFFENDING"
|
||||||
echo ""
|
echo ""
|
||||||
echo "The actual matched values are NOT echoed here, deliberately —"
|
echo "The actual matched values are NOT echoed here, deliberately —"
|
||||||
echo "round-tripping a leaked credential into CI logs widens the blast"
|
echo "round-tripping a leaked credential into CI logs widens the blast"
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from a2a.server.agent_execution import RequestContext
|
from a2a.server.agent_execution import RequestContext
|
||||||
@ -89,33 +90,46 @@ def append_peer_guidance(
|
|||||||
|
|
||||||
|
|
||||||
def summarize_peer_cards(peers: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def summarize_peer_cards(peers: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Return compact peer metadata for prompt rendering."""
|
"""Return compact peer metadata for prompt rendering.
|
||||||
|
|
||||||
|
Falls back to the registry row's `name` and `role` when `agent_card` is
|
||||||
|
null or unparseable so peers stay visible to delegators even before
|
||||||
|
their A2A discovery roundtrip has populated a card. Without this
|
||||||
|
fallback a coordinator-tier workspace with N freshly-created worker
|
||||||
|
peers would render an empty `## Your Peers` section and refuse to
|
||||||
|
delegate (the regression behind the 2026-04-27 Design Director
|
||||||
|
discovery bug).
|
||||||
|
"""
|
||||||
summaries: list[dict[str, Any]] = []
|
summaries: list[dict[str, Any]] = []
|
||||||
for peer in peers:
|
for peer in peers:
|
||||||
agent_card = peer.get("agent_card")
|
agent_card = peer.get("agent_card")
|
||||||
if not agent_card:
|
|
||||||
continue
|
|
||||||
if isinstance(agent_card, str):
|
if isinstance(agent_card, str):
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
|
|
||||||
agent_card = json.loads(agent_card)
|
agent_card = json.loads(agent_card)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
agent_card = None
|
||||||
if not isinstance(agent_card, dict):
|
if not isinstance(agent_card, dict):
|
||||||
continue
|
agent_card = None
|
||||||
|
|
||||||
|
if agent_card:
|
||||||
|
skills_raw = agent_card.get("skills") or []
|
||||||
|
skills = [
|
||||||
|
s.get("name", s.get("id", ""))
|
||||||
|
for s in skills_raw
|
||||||
|
if isinstance(s, dict)
|
||||||
|
]
|
||||||
|
name = agent_card.get("name") or peer.get("name") or "Unknown"
|
||||||
|
else:
|
||||||
|
skills = []
|
||||||
|
name = peer.get("name") or "Unknown"
|
||||||
|
|
||||||
skills = agent_card.get("skills", [])
|
|
||||||
summaries.append(
|
summaries.append(
|
||||||
{
|
{
|
||||||
"id": peer.get("id", "unknown"),
|
"id": peer.get("id", "unknown"),
|
||||||
"name": agent_card.get("name", peer.get("name", "Unknown")),
|
"name": name,
|
||||||
|
"role": peer.get("role") or "",
|
||||||
"status": peer.get("status", "unknown"),
|
"status": peer.get("status", "unknown"),
|
||||||
"skills": [
|
"skills": skills,
|
||||||
s.get("name", s.get("id", ""))
|
|
||||||
for s in skills
|
|
||||||
if isinstance(s, dict)
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return summaries
|
return summaries
|
||||||
@ -140,6 +154,8 @@ def build_peer_section(
|
|||||||
parts.append(f"- **{peer['name']}** (id: `{peer['id']}`, status: {peer['status']})")
|
parts.append(f"- **{peer['name']}** (id: `{peer['id']}`, status: {peer['status']})")
|
||||||
if peer["skills"]:
|
if peer["skills"]:
|
||||||
parts.append(f" Skills: {', '.join(peer['skills'])}")
|
parts.append(f" Skills: {', '.join(peer['skills'])}")
|
||||||
|
elif peer.get("role"):
|
||||||
|
parts.append(f" Role: {peer['role']}")
|
||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(instruction)
|
parts.append(instruction)
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|||||||
@ -203,8 +203,11 @@ def test_peer_capabilities_format(tmp_path):
|
|||||||
assert "**Echo Agent** (id: `peer-1`, status: online)" in result
|
assert "**Echo Agent** (id: `peer-1`, status: online)" in result
|
||||||
assert "Skills: echo, repeat" in result
|
assert "Skills: echo, repeat" in result
|
||||||
assert "delegate_to_workspace" in result
|
assert "delegate_to_workspace" in result
|
||||||
# peer-2 has no agent_card so it's skipped
|
# peer-2 has no agent_card but DOES have a DB name + status — must
|
||||||
assert "Silent Agent" not in result
|
# still render so coordinators can delegate to freshly-created peers
|
||||||
|
# whose A2A discovery hasn't populated a card yet (regression of the
|
||||||
|
# 2026-04-27 Design Director discovery bug).
|
||||||
|
assert "**Silent Agent** (id: `peer-2`, status: offline)" in result
|
||||||
|
|
||||||
|
|
||||||
def test_peer_with_json_string_agent_card(tmp_path):
|
def test_peer_with_json_string_agent_card(tmp_path):
|
||||||
|
|||||||
111
workspace/tests/test_shared_runtime_peer_summary.py
Normal file
111
workspace/tests/test_shared_runtime_peer_summary.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Pin peer-summary fallback when agent_card is missing.
|
||||||
|
|
||||||
|
Regresses the 2026-04-27 Design Director discovery bug:
|
||||||
|
`summarize_peer_cards()` previously skipped any peer whose `agent_card`
|
||||||
|
was null or unparseable, so a coordinator with freshly-created workers
|
||||||
|
saw an empty `## Your Peers` section in its system prompt and refused
|
||||||
|
to delegate. The registry endpoint already returns DB `name` + `role`
|
||||||
|
on every row regardless of agent_card state — falling back to those
|
||||||
|
keeps peers visible while A2A discovery catches up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from shared_runtime import build_peer_section, summarize_peer_cards
|
||||||
|
|
||||||
|
|
||||||
|
def _peer(**overrides):
|
||||||
|
base = {
|
||||||
|
"id": "ws-1",
|
||||||
|
"name": "DB Name",
|
||||||
|
"role": "DB Role",
|
||||||
|
"status": "active",
|
||||||
|
"agent_card": None,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_includes_peer_with_null_agent_card_using_db_fields():
|
||||||
|
summaries = summarize_peer_cards([_peer()])
|
||||||
|
assert len(summaries) == 1
|
||||||
|
assert summaries[0]["id"] == "ws-1"
|
||||||
|
assert summaries[0]["name"] == "DB Name"
|
||||||
|
assert summaries[0]["role"] == "DB Role"
|
||||||
|
assert summaries[0]["status"] == "active"
|
||||||
|
assert summaries[0]["skills"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_prefers_agent_card_name_over_db_name():
|
||||||
|
peer = _peer(
|
||||||
|
agent_card={"name": "Card Name", "skills": [{"name": "draft-spec"}]}
|
||||||
|
)
|
||||||
|
summaries = summarize_peer_cards([peer])
|
||||||
|
assert summaries[0]["name"] == "Card Name"
|
||||||
|
assert summaries[0]["skills"] == ["draft-spec"]
|
||||||
|
assert summaries[0]["role"] == "DB Role"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_handles_string_agent_card_json():
|
||||||
|
peer = _peer(agent_card='{"name": "JSON Name", "skills": []}')
|
||||||
|
summaries = summarize_peer_cards([peer])
|
||||||
|
assert summaries[0]["name"] == "JSON Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_falls_back_when_agent_card_string_is_malformed():
|
||||||
|
peer = _peer(agent_card="not-valid-json")
|
||||||
|
summaries = summarize_peer_cards([peer])
|
||||||
|
assert len(summaries) == 1
|
||||||
|
assert summaries[0]["name"] == "DB Name"
|
||||||
|
assert summaries[0]["role"] == "DB Role"
|
||||||
|
assert summaries[0]["skills"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_falls_back_when_agent_card_is_wrong_type():
|
||||||
|
peer = _peer(agent_card=42)
|
||||||
|
summaries = summarize_peer_cards([peer])
|
||||||
|
assert len(summaries) == 1
|
||||||
|
assert summaries[0]["name"] == "DB Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_handles_missing_role_and_name_with_unknown_default():
|
||||||
|
peer = {"id": "ws-2", "status": "active", "agent_card": None}
|
||||||
|
summaries = summarize_peer_cards([peer])
|
||||||
|
assert summaries[0]["name"] == "Unknown"
|
||||||
|
assert summaries[0]["role"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_peer_section_renders_role_when_skills_empty():
|
||||||
|
section = build_peer_section([_peer()])
|
||||||
|
assert "## Your Peers" in section
|
||||||
|
assert "**DB Name**" in section
|
||||||
|
assert "Role: DB Role" in section
|
||||||
|
assert "Skills:" not in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_peer_section_prefers_skills_over_role_when_card_present():
|
||||||
|
peer = _peer(
|
||||||
|
agent_card={"name": "Worker", "skills": [{"name": "design"}, {"name": "review"}]}
|
||||||
|
)
|
||||||
|
section = build_peer_section([peer])
|
||||||
|
assert "Skills: design, review" in section
|
||||||
|
assert "Role: DB Role" not in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_peer_section_mixed_peers():
|
||||||
|
peers = [
|
||||||
|
_peer(id="ws-a"),
|
||||||
|
_peer(
|
||||||
|
id="ws-b",
|
||||||
|
agent_card={"name": "Card B", "skills": [{"name": "build"}]},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
section = build_peer_section(peers)
|
||||||
|
assert "id: `ws-a`" in section
|
||||||
|
assert "id: `ws-b`" in section
|
||||||
|
assert "Role: DB Role" in section
|
||||||
|
assert "Skills: build" in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_peer_section_empty_when_no_peers():
|
||||||
|
assert build_peer_section([]) == ""
|
||||||
Loading…
Reference in New Issue
Block a user