From 1b85da96d171a207c1130534f172b26ca604338a Mon Sep 17 00:00:00 2001 From: ms280690 Date: Sun, 11 Oct 2026 19:24:37 -0700 Subject: [PATCH 1/2] feat(#116): daily org-wide asset register roll-up rollup.py merges the inventory.json published by every repository's asset-inventory-latest release into one register: assets.json with the repo attached to each record, owners.md (assets per owner, project and environment), unowned.md (tag violations or a team-* owner missing from policies/tags/teams.yml), data-classes.md (confidential and restricted), the org diagram through the asset-inventory renderer, summary.md and SHA256SUMS. Inventories older than stale-days are flagged. asset-register-rollup.yml runs daily: a read-only GitHub App token (falling back to GITHUB_TOKEN for public repositories) lists the org's repositories, downloads and checksum-verifies each inventory, runs the composite, publishes asset-register-latest plus a dated tag with 90 retained, and opens, updates or closes the "Asset register: unowned or stale assets" issue. Nothing in the run can write to a consuming repository. policies/tags/teams.yml starts empty, which accepts any team-* owner until the real team names are filled in. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NPH9145e4gFo6bmXvMgBoF --- .github/actions/asset-register/action.yml | 60 +++++ .github/actions/asset-register/rollup.py | 219 ++++++++++++++++++ .github/workflows/asset-register-rollup.yml | 171 ++++++++++++++ .github/workflows/ci.yml | 40 ++++ README.md | 8 + docs/approved-actions.md | 1 + docs/asset-inventory.md | 25 ++ policies/tags/teams.yml | 6 + .../fixtures/expected/register.dot | 36 +++ .../inventories/clean-repo/inventory.json | 39 ++++ .../inventories/geo-api/inventory.json | 60 +++++ .../prescient-infra/inventory.json | 82 +++++++ tests/asset-register/fixtures/teams.yml | 4 + tests/asset-register/run.sh | 95 ++++++++ 14 files changed, 846 insertions(+) create mode 100644 .github/actions/asset-register/action.yml create mode 100644 .github/actions/asset-register/rollup.py create mode 100644 .github/workflows/asset-register-rollup.yml create mode 100644 policies/tags/teams.yml create mode 100644 tests/asset-register/fixtures/expected/register.dot create mode 100644 tests/asset-register/fixtures/inventories/clean-repo/inventory.json create mode 100644 tests/asset-register/fixtures/inventories/geo-api/inventory.json create mode 100644 tests/asset-register/fixtures/inventories/prescient-infra/inventory.json create mode 100644 tests/asset-register/fixtures/teams.yml create mode 100755 tests/asset-register/run.sh diff --git a/.github/actions/asset-register/action.yml b/.github/actions/asset-register/action.yml new file mode 100644 index 0000000..fe07097 --- /dev/null +++ b/.github/actions/asset-register/action.yml @@ -0,0 +1,60 @@ +name: "Asset Register" +description: | + Merges the per-repository inventory.json files published by + asset-inventory.yml into the org-wide register (issue #116): assets.json, + owners.md, unowned.md (tag violations or an owner outside + policies/tags/teams.yml), data-classes.md (confidential and restricted), + register.dot and register.svg, summary.md and SHA256SUMS. Flags any + inventory older than stale-days. Never fails on findings. + + Required permissions on the calling job: contents: read. +inputs: + inventories-dir: + description: "Directory with one subdirectory per repository, each holding a verified inventory.json." + required: true + teams-file: + description: "Team list (policies/tags/teams.yml). Empty uses the bundled file; an empty list accepts any team-* owner." + default: "" + stale-days: + description: "An inventory older than this many days is reported as stale." + default: "7" + output-dir: + description: "Where the register files are written." + default: "register" +outputs: + repo-count: + description: "Repositories merged." + value: ${{ steps.build.outputs.repo-count }} + unowned-count: + description: "Assets with tag violations or an owner outside the team list." + value: ${{ steps.build.outputs.unowned-count }} + stale-count: + description: "Repositories whose inventory is older than stale-days." + value: ${{ steps.build.outputs.stale-count }} + summary-path: + description: "Markdown summary for the tracking issue." + value: ${{ steps.build.outputs.summary-path }} +runs: + using: "composite" + steps: + - name: Install graphviz + shell: bash + run: | + set -euo pipefail + if ! command -v dot >/dev/null && command -v apt-get >/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends graphviz + fi + + - name: Build register + id: build + shell: bash + env: + INVENTORIES: ${{ inputs.inventories-dir }} + TEAMS: ${{ inputs.teams-file }} + STALE_DAYS: ${{ inputs.stale-days }} + OUTPUT_DIR: ${{ inputs.output-dir }} + BUNDLED_TEAMS: ${{ github.action_path }}/../../../policies/tags/teams.yml + run: | + set -euo pipefail + python3 -I "${GITHUB_ACTION_PATH}/rollup.py" --inventories "${INVENTORIES}" --output-dir "${OUTPUT_DIR}" \ + --teams "${TEAMS:-${BUNDLED_TEAMS}}" --stale-days "${STALE_DAYS}" --svg diff --git a/.github/actions/asset-register/rollup.py b/.github/actions/asset-register/rollup.py new file mode 100644 index 0000000..257b195 --- /dev/null +++ b/.github/actions/asset-register/rollup.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Org-wide asset register from per-repository inventories (issue #116). + +Usage: rollup.py --inventories DIR --output-dir DIR [--teams policies/tags/teams.yml] + [--stale-days 7] [--svg] [--now ISO8601] + +--inventories holds one subdirectory per repository with the inventory.json +published by asset-inventory.yml (checksums are verified by the caller). +Writes assets.json, owners.md, unowned.md, data-classes.md, register.dot +(and register.svg with --svg when dot is installed), summary.md and +SHA256SUMS. An asset is unowned when it has tag violations or its owner is a +team-* value missing from the team list (an empty list accepts any team). +Exit 0 always; the tracking issue is the workflow's job. +Standard library only; run with python3 -I. +""" + +import argparse +import hashlib +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +TITLE = "asset-register" +TEAMS_PATH = "policies/tags/teams.yml" + + +def load_inventory_module(): + """Grouping and DOT renderer from the sibling asset-inventory composite.""" + sys.dont_write_bytecode = True # keep __pycache__ out of the checkout + path = Path(__file__).resolve().parent.parent / "asset-inventory" / "inventory.py" + spec = importlib.util.spec_from_file_location("inventory", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def warn(msg): + print(f"::warning title={TITLE}::{msg}") + + +def parse_ts(value): + try: + return datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) + except (TypeError, ValueError): + return None + + +def load_teams(path): + """Names under `teams:` in a flat YAML list; None when no file was given.""" + if not path: + return None + teams, in_list = [], False + for line in Path(path).read_text().splitlines(): + stripped = line.split("#", 1)[0].rstrip() + if not stripped: + continue + if re.match(r"^teams:\s*\[\s*\]\s*$", stripped): + return [] + if re.match(r"^teams:\s*$", stripped): + in_list = True + continue + m = re.match(r"^\s+-\s*['\"]?([^'\"]+?)['\"]?\s*$", stripped) + if in_list and m: + teams.append(m.group(1)) + elif in_list and not stripped.startswith(" "): + in_list = False + return teams + + +def load_inventories(root): + for path in sorted(Path(root).glob("*/inventory.json")): + rel = path.relative_to(root).as_posix() + try: + with open(path) as f: + inv = json.load(f) + inv["resources"] + inv["repo"] + except (OSError, ValueError, KeyError, TypeError) as exc: + warn(f"{rel} unreadable, skipped: {exc}") + continue + yield inv + + +def unowned_reason(asset, teams): + if asset["tag_violations"]: + return "missing " + ", ".join(asset["tag_violations"]) + owner = asset["tags"].get("owner", "") + if teams and owner.startswith("team-") and owner not in teams: + return f"owner {owner} is not in {TEAMS_PATH}" + return None + + +def table(headers, rows): + out = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)] + out += ["| " + " | ".join(str(c) for c in r) + " |" for r in rows] + return out + + +def write(path, text): + with open(path, "w") as f: + f.write(text if text.endswith("\n") else text + "\n") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--inventories", required=True) + ap.add_argument("--output-dir", required=True) + ap.add_argument("--teams") + ap.add_argument("--stale-days", type=int, default=7) + ap.add_argument("--svg", action="store_true") + ap.add_argument("--now") + args = ap.parse_args() + + now = parse_ts(args.now) if args.now else datetime.now(timezone.utc).replace(microsecond=0) + teams = load_teams(args.teams) + out = Path(args.output_dir) + out.mkdir(parents=True, exist_ok=True) + + repos, assets = [], [] + for inv in load_inventories(args.inventories): + generated = parse_ts(inv.get("generated_at")) + age = (now - generated).days if generated else None + stale = age is None or age > args.stale_days + if stale: + warn(f"{inv['repo']} inventory is {age if age is not None else 'an unknown number of'} days old (stale after {args.stale_days})") + repos.append({"repo": inv["repo"], "generated_at": inv.get("generated_at"), "stack_kind": inv.get("stack_kind"), + "resources": len(inv["resources"]), "violations": sum(1 for r in inv["resources"] if r.get("tag_violations")), + "age_days": age, "stale": stale}) + for r in inv["resources"]: + a = dict(r, repo=inv["repo"]) + a["unowned_reason"] = unowned_reason(a, teams) + assets.append(a) + if not repos: + warn(f"no inventories found under {args.inventories}") + repos.sort(key=lambda r: r["repo"]) + assets.sort(key=lambda a: (a["repo"], a["stack"], a["id"])) + unowned = [a for a in assets if a["unowned_reason"]] + stale_repos = [r for r in repos if r["stale"]] + + register = { + "generated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "stale_days": args.stale_days, + "teams_file": args.teams, "repos": repos, + "counts": {"repos": len(repos), "assets": len(assets), "unowned": len(unowned), "stale_repos": len(stale_repos)}, + "assets": assets, + } + with open(out / "assets.json", "w") as f: + json.dump(register, f, indent=1) + f.write("\n") + + # owners.md: assets per owner, project, environment + counts = {} + for a in assets: + key = (a["tags"].get("owner", "(none)"), a["tags"].get("project", "(none)"), a["tags"].get("environment", "(none)")) + counts[key] = counts.get(key, 0) + 1 + write(out / "owners.md", "\n".join( + ["# Assets by owner", "", f"Generated {register['generated_at']} from {len(repos)} repositories.", ""] + + table(["Owner", "Project", "Environment", "Assets"], [(*k, v) for k, v in sorted(counts.items())]))) + + write(out / "unowned.md", "\n".join( + ["# Unowned assets", "", "Tag violations, or an owner outside the team list. Each row needs an owner before the next audit.", ""] + + table(["Repo", "Resource", "Owner", "Reason"], + [(a["repo"], f"`{a['id']}`", a["tags"].get("owner", ""), a["unowned_reason"]) for a in unowned]))) + + sensitive = [a for a in assets if a["tags"].get("data-class") in ("confidential", "restricted")] + write(out / "data-classes.md", "\n".join( + ["# Confidential and restricted assets", "", "Feeds the storage-location inventory of the data classification policy.", ""] + + table(["Repo", "Resource", "Type", "Data class", "Owner", "Stack", "Region"], + [(a["repo"], f"`{a['id']}`", a["type"], a["tags"]["data-class"], a["tags"].get("owner", ""), a["stack"], a["region"] or "") + for a in sensitive]))) + + inventory = load_inventory_module() + labelled = [dict(a, name=f'{a["name"]} ({a["repo"].split("/")[-1]})', stack=a["repo"] + "/" + a["stack"]) for a in assets] + dot = inventory.render_dot(labelled) + write(out / "register.dot", dot) + if args.svg: + if shutil.which("dot"): + subprocess.run(["dot", "-Tsvg", "-o", str(out / "register.svg")], input=dot, text=True, check=True) + else: + warn("graphviz dot not installed; SVG not rendered") + + summary = [f"## Asset register: {len(assets)} assets across {len(repos)} repos, {len(unowned)} unowned, " + f"{len(stale_repos)} stale {'inventory' if len(stale_repos) == 1 else 'inventories'}", ""] + summary += table(["Repo", "Stack kind", "Resources", "Violations", "Inventory age (days)", "Stale"], + [(r["repo"], r["stack_kind"], r["resources"], r["violations"], r["age_days"] if r["age_days"] is not None else "unknown", + "yes" if r["stale"] else "") for r in repos]) + if unowned: + summary += ["", "### Unowned", ""] + table(["Repo", "Resource", "Owner", "Reason"], + [(a["repo"], f"`{a['id']}`", a["tags"].get("owner", ""), a["unowned_reason"]) for a in unowned[:50]]) + if len(unowned) > 50: + summary.append(f"\n{len(unowned) - 50} more in unowned.md.") + write(out / "summary.md", "\n".join(summary)) + + with open(out / "SHA256SUMS", "w") as f: + for name in ("assets.json", "owners.md", "unowned.md", "data-classes.md", "register.dot", "register.svg", "summary.md"): + p = out / name + if p.is_file(): + f.write(f"{hashlib.sha256(p.read_bytes()).hexdigest()} {name}\n") + + step = os.environ.get("GITHUB_STEP_SUMMARY") + if step: + with open(step, "a") as f: + f.write("\n".join(summary) + "\n") + gh_out = os.environ.get("GITHUB_OUTPUT") + if gh_out: + with open(gh_out, "a") as f: + f.write(f"repo-count={len(repos)}\nunowned-count={len(unowned)}\nstale-count={len(stale_repos)}\n" + f"summary-path={out / 'summary.md'}\n") + print(f"{TITLE}: {len(assets)} assets, {len(repos)} repos, {len(unowned)} unowned, {len(stale_repos)} stale") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/asset-register-rollup.yml b/.github/workflows/asset-register-rollup.yml new file mode 100644 index 0000000..84d2257 --- /dev/null +++ b/.github/workflows/asset-register-rollup.yml @@ -0,0 +1,171 @@ +name: Asset Register Roll-up + +# Daily org-wide asset register (issue #116, plan Step 31 Standard). Reads the +# asset-inventory-latest release of every sparkgeo repository with a +# read-only GitHub App token, verifies each inventory against its SHA256SUMS, +# merges them with the asset-register composite and publishes the register to +# the rolling asset-register-latest release plus a dated tag (90 retained). +# Opens or updates a tracking issue while any asset is unowned or any +# inventory is stale, and closes it once a run is clean. +# +# The App needs only contents: read and metadata: read on the org's repos; +# nothing here can write to a consuming repository. Without the App secrets +# the run falls back to GITHUB_TOKEN, which sees public repositories only. +# Runs only in this repo on a schedule: no user-controlled input reaches a +# run: block. + +on: + schedule: + - cron: '41 6 * * *' # daily, 06:41 UTC, after consuming repos' overnight runs + workflow_dispatch: + +permissions: {} + +concurrency: + group: asset-register-rollup + cancel-in-progress: false + +jobs: + rollup: + name: Roll up + runs-on: ubuntu-latest + permissions: + contents: write # releases and tags in this repo + issues: write # tracking issue + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Pick the read token + id: pick + env: + APP_ID: ${{ secrets.ASSET_REGISTER_APP_ID }} + APP_KEY: ${{ secrets.ASSET_REGISTER_APP_PRIVATE_KEY }} + run: | + if [ -n "${APP_ID}" ] && [ -n "${APP_KEY}" ]; then + echo "app=true" >> "${GITHUB_OUTPUT}" + else + echo "app=false" >> "${GITHUB_OUTPUT}" + echo "::warning title=asset-register::ASSET_REGISTER_APP_ID or ASSET_REGISTER_APP_PRIVATE_KEY not set; only public repositories are read" + fi + + - name: Mint a read-only App token + id: app + if: steps.pick.outputs.app == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.ASSET_REGISTER_APP_ID }} + private-key: ${{ secrets.ASSET_REGISTER_APP_PRIVATE_KEY }} + # The register must read every repository in the org; the token is + # read-only and limited to the two permissions below. + owner: ${{ github.repository_owner }} # zizmor: ignore[github-app] + permission-contents: read + permission-metadata: read + + - name: Collect inventories + id: collect + env: + GH_TOKEN: ${{ steps.app.outputs.token || github.token }} + ORG: ${{ github.repository_owner }} + DIR: ${{ runner.temp }}/inventories + run: | + set -euo pipefail + mkdir -p "${DIR}" + found=0 + while IFS= read -r repo; do + [ -n "${repo}" ] || continue + name="${repo#*/}" + if ! gh release view asset-inventory-latest -R "${repo}" --json id > /dev/null 2>&1; then + continue + fi + mkdir -p "${DIR}/${name}" + if ! gh release download asset-inventory-latest -R "${repo}" -D "${DIR}/${name}" --clobber -p inventory.json -p SHA256SUMS; then + echo "::warning title=asset-register::${repo}: download failed, skipped" + rm -rf "${DIR:?}/${name}" + continue + fi + if ! ( cd "${DIR}/${name}" && sha256sum -c --ignore-missing --quiet SHA256SUMS ); then + echo "::warning title=asset-register::${repo}: SHA256SUMS mismatch, skipped" + rm -rf "${DIR:?}/${name}" + continue + fi + found=$((found + 1)) + echo "${repo}: collected" + done < <(gh repo list "${ORG}" --limit 1000 --no-archived --json nameWithOwner --jq '.[].nameWithOwner' | sort) + echo "found=${found}" >> "${GITHUB_OUTPUT}" + echo "${found} inventories collected" + + - id: register + uses: ./.github/actions/asset-register + with: + inventories-dir: ${{ runner.temp }}/inventories + output-dir: ${{ runner.temp }}/register + + - name: Publish dated and latest releases + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TARGET: ${{ github.sha }} + DIR: ${{ runner.temp }}/register + SUMMARY: ${{ steps.register.outputs.summary-path }} + run: | + set -euo pipefail + DATE="$(date -u +%F)" + { + echo "Org-wide asset register ${DATE} (UTC), built by \`.github/actions/asset-register\` at ${TARGET}." + echo + cat "${SUMMARY}" + echo + echo "Verify with \`sha256sum -c SHA256SUMS\`. Format: docs/asset-inventory.md." + } > "${RUNNER_TEMP}/release-notes.md" + publish() { # ; recreate so a rerun on the same day, or the rolling tag, is replaced + gh release delete "${1}" -R "${REPO}" --cleanup-tag --yes 2>/dev/null || true + gh release create "${1}" -R "${REPO}" --target "${TARGET}" --title "${2}" \ + --notes-file "${RUNNER_TEMP}/release-notes.md" --latest=false "${DIR}"/* + } + publish "asset-register-${DATE}" "Asset register ${DATE}" + publish "asset-register-latest" "Asset register (latest: ${DATE})" + + - name: Prune dated releases beyond 90 + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh release list -R "${REPO}" --limit 500 --json tagName \ + --jq '.[].tagName | select(test("^asset-register-[0-9]{4}-[0-9]{2}-[0-9]{2}$"))' \ + | sort -r | tail -n +91 \ + | while IFS= read -r tag; do + echo "pruning ${tag}" + gh release delete "${tag}" -R "${REPO}" --cleanup-tag --yes + done + + - name: Open, update or close the tracking issue + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + UNOWNED: ${{ steps.register.outputs.unowned-count }} + STALE: ${{ steps.register.outputs.stale-count }} + SUMMARY: ${{ steps.register.outputs.summary-path }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + TITLE="Asset register: unowned or stale assets" + existing="$(gh issue list -R "${REPO}" --state open --search "\"${TITLE}\" in:title" --json number --jq '.[0].number // empty')" + if [ "${UNOWNED}" = "0" ] && [ "${STALE}" = "0" ]; then + if [ -n "${existing}" ]; then + gh issue close "${existing}" -R "${REPO}" --comment "Clean run on $(date -u +%F): every asset has an owner and every inventory is fresh. ${RUN_URL}" + fi + exit 0 + fi + { + echo "Run of $(date -u +%F): ${RUN_URL}. Full register: release \`asset-register-latest\` (\`unowned.md\`, \`owners.md\`, \`data-classes.md\`)." + echo + cat "${SUMMARY}" + } > "${RUNNER_TEMP}/issue-body.md" + if [ -n "${existing}" ]; then + gh issue comment "${existing}" -R "${REPO}" --body-file "${RUNNER_TEMP}/issue-body.md" + else + gh issue create -R "${REPO}" --title "${TITLE}" --label security --body-file "${RUNNER_TEMP}/issue-body.md" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebedb83..4123a84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,46 @@ jobs: name: asset-inventory-fixtures path: ${{ runner.temp }}/asset-inventory-tofu/ + # Asset register (#116): roll-up fixtures, then the composite on the same + # fixtures. The scheduled asset-register-rollup.yml needs the org's + # repositories and App secrets, so it is exercised with workflow_dispatch. + asset-register: + name: Asset Register + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Roll-up fixtures + run: tests/asset-register/run.sh + + - id: register + uses: ./.github/actions/asset-register + with: + inventories-dir: tests/asset-register/fixtures/inventories + teams-file: tests/asset-register/fixtures/teams.yml + output-dir: ${{ runner.temp }}/register + + - name: Outputs, SVG and checksums + env: + REPOS: ${{ steps.register.outputs.repo-count }} + UNOWNED: ${{ steps.register.outputs.unowned-count }} + DIR: ${{ runner.temp }}/register + run: | + set -euo pipefail + [ "${REPOS}" = "3" ] || { echo "::error title=asset-register::expected 3 repos, got ${REPOS}"; exit 1; } + [ "${UNOWNED}" = "2" ] || { echo "::error title=asset-register::expected 2 unowned, got ${UNOWNED}"; exit 1; } + grep -q '<svg' "${DIR}/register.svg" + ( cd "${DIR}" && sha256sum -c --quiet SHA256SUMS ) + # Dogfood the app lint gate by calling the reusable workflow directly. # (MegaLinter has no composite under .github/actions — its only reliable gate # is its exit code, which needs continue-on-error + if:always; those are diff --git a/README.md b/README.md index 55c177a..769175a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ All action references in this repo are pinned to full commit SHAs. See [CONTRIBU |---|---|---|---| | CI | [`ci.yml`](.github/workflows/ci.yml) | `push` to `main`, `pull_request`, `schedule` (weekly), `workflow_dispatch` | Dogfoods all composite actions in this repo; serves as a live reference implementation | | Asset Inventory | [`asset-inventory.yml`](.github/workflows/asset-inventory.yml) | `workflow_call` | Reusable register — builds `inventory.json` and a tag-grouped diagram from OpenTofu plan JSON or CDK synth output; diffs against the last published inventory on PRs, publishes a rolling `asset-inventory-latest` release on the default branch | +| Asset Register Roll-up | [`asset-register-rollup.yml`](.github/workflows/asset-register-rollup.yml) | `schedule` (daily), `workflow_dispatch` | Reads every repo's `asset-inventory-latest` release with a read-only App token, merges them into the org-wide register (`assets.json`, `owners.md`, `unowned.md`, `data-classes.md`, diagram) on the rolling `asset-register-latest` release plus dated tags; tracking issue while assets are unowned or inventories stale | | Secrets Pre-commit | [`secrets-precommit.yml`](.github/workflows/secrets-precommit.yml) | `workflow_call` | Reusable Gitleaks gate — call from a consuming repo's PR workflow to block secrets in CI | | Secrets PR Scan | [`secrets-scan.yml`](.github/workflows/secrets-scan.yml) | `workflow_call` | Reusable TruffleHog gate — verifies matched credentials are live; hard-blocks on verified secrets; uploads SARIF to the Security tab | | Lint Pre-commit | [`lint-precommit.yml`](.github/workflows/lint-precommit.yml) | `workflow_call` | Reusable gate that runs the consuming repo's `.pre-commit-config.yaml` hooks in CI; language-agnostic; shared across app/IaC/Helm lint stages | @@ -45,6 +46,7 @@ gh api repos/sparkgeo/github-actions/commits/main --jq '.sha' | Kubeconform | [`kubeconform`](.github/actions/kubeconform/action.yml) | Renders Helm charts (`helm template`) and Kustomize overlays (`kustomize build`) and validates output against Kubernetes API schemas. Installs a checksum-verified kubeconform binary | `version` (default: `0.8.0`), `charts-dir` (default: `charts`), `kustomize-dir` (default: `""`), `kubernetes-version` (default: `1.32.0`), `ignore-missing-schemas` (default: `false`) | | K8s Policy | [`k8s-policy`](.github/actions/k8s-policy/action.yml) | Renders Helm charts / Kustomize overlays and evaluates them with conftest against the bundled org policy set ([`policies/`](.github/actions/k8s-policy/policies)); error annotations + job summary per violation. Installs a checksum-verified conftest binary | `version` (default: `0.71.1`), `charts-dir` (default: `charts`), `kustomize-dir` (default: `""`), `policy-path` (default: `""`), `data-path` (default: `""`), `fail-on-warn` (default: `false`), `require-tags` (default: `true`) | | Asset Inventory | [`asset-inventory`](.github/actions/asset-inventory/action.yml) | One record per planned resource (id, type, stack, region, tags, missing required tags, source) from OpenTofu plan JSON or CDK templates; Graphviz DOT and SVG (installs graphviz if missing) plus Mermaid grouped project, environment, owner; diff against a base inventory. Never fails on findings | `stack` (required: `tofu` or `cdk`), `input-dir` (required), `required-tags`, `account`, `base-inventory`, `mermaid-max-nodes` (default: `60`), `output-dir` | +| Asset Register | [`asset-register`](.github/actions/asset-register/action.yml) | Merges per-repo `inventory.json` files into the org register: `assets.json`, `owners.md`, `unowned.md` (tag violations or an owner outside `policies/tags/teams.yml`), `data-classes.md`, Graphviz diagram, `SHA256SUMS`; flags inventories older than `stale-days` | `inventories-dir` (required), `teams-file`, `stale-days` (default: `7`), `output-dir` (default: `register`) | ### GitHub Actionlint @@ -482,6 +484,12 @@ python3 -I .github/actions/asset-inventory/inventory.py --stack tofu --input pla --output inventory.json --svg inventory.svg --mermaid inventory.mmd --report report.md ``` +### Asset Register (org roll-up) + +[`asset-register-rollup.yml`](.github/workflows/asset-register-rollup.yml) runs daily in this repo and builds the org-wide register from every repository's published inventory (issue #116): one `assets.json` with the repo attached to each record, `owners.md` (assets per owner, project, environment), `unowned.md` (tag violations, or a `team-*` owner missing from [`policies/tags/teams.yml`](policies/tags/teams.yml)), `data-classes.md` (confidential and restricted assets, for the storage-location inventory of the classification policy), `register.svg`, `summary.md` and `SHA256SUMS`. Each run replaces the `asset-register-latest` release and adds `asset-register-YYYY-MM-DD` (90 kept). An "Asset register: unowned or stale assets" issue is opened or updated while any asset is unowned or any inventory is older than seven days, and closed by the next clean run. + +Reading private repositories needs a GitHub App with `contents: read` and `metadata: read` installed on the org, stored as the `ASSET_REGISTER_APP_ID` and `ASSET_REGISTER_APP_PRIVATE_KEY` secrets of this repo; without them the run reads public repositories only and says so. Nothing in the roll-up can write to a consuming repository. Auditors fetch a dated snapshot with `gh release download asset-register-2026-10-12 -R sparkgeo/github-actions` and check it with `sha256sum -c SHA256SUMS`; format in [`docs/asset-inventory.md`](docs/asset-inventory.md). + ## Consuming repo CI setup Public and private repos use different subsets of these actions. The key differences are `harden-runner` (sends egress telemetry to StepSecurity — omit on private repos) and `scorecard` (requires public visibility to produce meaningful scores). diff --git a/docs/approved-actions.md b/docs/approved-actions.md index 59abe39..1283e2a 100644 --- a/docs/approved-actions.md +++ b/docs/approved-actions.md @@ -69,6 +69,7 @@ These are not GitHub Actions (no `uses:` reference) so the org allowlist does no | Tool | Used in | Pinned version | How it is installed | Review date | |---|---|---|---|---| +| `actions/create-github-app-token` | `asset-register-rollup.yml` | `v3.2.0` (`bcd2ba49`) | GitHub-maintained. Mints a short-lived installation token for the read-only asset-register App (`contents: read`, `metadata: read`); nothing else in the roll-up can reach other repositories. | 2026-10-12 | | `gitleaks` | `gitleaks` composite action, `secrets-precommit.yml`, `.pre-commit-config.yaml` | `v8.30.1` (`83d9cd68`) | Binary downloaded from the GitHub release and **verified against the published SHA-256 checksum** before use. The `gitleaks/gitleaks-action` Action is deliberately avoided — it requires a paid `GITLEAKS_LICENSE` for organisation accounts. | 2026-06-10 | | `trufflehog` | `trufflehog` composite action, `secrets-scan.yml` | `v3.95.5` | Binary downloaded from the GitHub release and **verified against the published SHA-256 checksum** before use. The `trufflesecurity/trufflehog` Action is avoided to keep the supply chain to a single checksum-verified download; findings are converted to SARIF in-action with `jq`. | 2026-06-10 | | `pre-commit` | `pre-commit` composite action, `lint-precommit.yml` | `4.6.0` | Run via `pipx run --spec pre-commit==4.6.0` (pipx is preinstalled on GitHub runners) — pinned version, ephemeral env, no PATH write. Hook versions themselves are pinned (to commit SHAs) in each consuming repo's `.pre-commit-config.yaml`. | 2026-06-10 | diff --git a/docs/asset-inventory.md b/docs/asset-inventory.md index a553804..ba98c81 100644 --- a/docs/asset-inventory.md +++ b/docs/asset-inventory.md @@ -45,6 +45,31 @@ Data sources, `AWS::CDK::Metadata` and anything that is not a managed resource a On a push to the default branch, `asset-inventory.yml` uploads `inventory.json`, `inventory.svg`, `inventory.mmd` and `SHA256SUMS` to the rolling `asset-inventory-latest` release of the calling repository (the caller grants `contents: write`). Pull requests download that `inventory.json` as the diff base. An org-wide roll-up reads each repository's release. +## Org-wide register + +`asset-register-rollup.yml` in `sparkgeo/github-actions` merges every repository's `asset-inventory-latest` inventory once a day (issue #116) and publishes: + +| File | Content | +|---|---| +| `assets.json` | `generated_at`, `stale_days`, `repos` (one row per repository: `generated_at`, `stack_kind`, `resources`, `violations`, `age_days`, `stale`), `counts` (`repos`, `assets`, `unowned`, `stale_repos`) and `assets`: every resource record from the inventories with `repo` and `unowned_reason` added | +| `owners.md` | assets per owner, project and environment | +| `unowned.md` | assets with tag violations or a `team-*` owner that is not in `policies/tags/teams.yml`; `<user>@sparkgeo.com` owners pass | +| `data-classes.md` | confidential and restricted assets with type, owner, stack and region | +| `register.dot`, `register.svg` | the org diagram, nodes labelled with their repository | +| `summary.md` | the text posted to the tracking issue | +| `SHA256SUMS` | checksums of the files above | + +Releases: `asset-register-latest` (rolling) and `asset-register-YYYY-MM-DD` (90 retained). An inventory older than seven days is stale and listed in the summary; a repository whose `SHA256SUMS` does not verify is skipped with a warning. The tracking issue "Asset register: unowned or stale assets" stays open until a run finds nothing. + +**Auditors.** A dated snapshot is the evidence for that day: + +```bash +gh release download asset-register-2026-10-12 -R sparkgeo/github-actions -D register-2026-10-12 +cd register-2026-10-12 && sha256sum -c SHA256SUMS +``` + +**App setup (org admin).** Create a GitHub App with repository permissions `Contents: read` and `Metadata: read`, install it on all repositories, and store its id and private key as the `ASSET_REGISTER_APP_ID` and `ASSET_REGISTER_APP_PRIVATE_KEY` secrets of `sparkgeo/github-actions`. The workflow mints a short-lived token per run with `actions/create-github-app-token`; without the secrets it falls back to `GITHUB_TOKEN` and sees public repositories only. + ## Topology add-ons (not wired) The register is a flat list grouped by tags; it does not draw dependencies. When a topology view is needed, two tools produce one from the same inputs and can run as an extra step in the caller: diff --git a/policies/tags/teams.yml b/policies/tags/teams.yml new file mode 100644 index 0000000..40fe702 --- /dev/null +++ b/policies/tags/teams.yml @@ -0,0 +1,6 @@ +# Teams that may appear as `owner: team-<name>` on a resource (issue #116). +# The asset register lists an asset as unowned when its owner is a team-* +# value that is not in this list; `<user>@sparkgeo.com` owners are accepted by +# the pattern in standard.yml. An empty list accepts any team-* owner; fill it +# in with the real team names so unknown teams surface in register/unowned.md. +teams: [] diff --git a/tests/asset-register/fixtures/expected/register.dot b/tests/asset-register/fixtures/expected/register.dot new file mode 100644 index 0000000..652290d --- /dev/null +++ b/tests/asset-register/fixtures/expected/register.dot @@ -0,0 +1,36 @@ +digraph inventory { + graph [rankdir=LR, fontname="Helvetica", fontsize=11, style="rounded", color="#999999"]; + node [shape=box, style="filled,rounded", fontname="Helvetica", fontsize=10, color="#999999"]; + subgraph "cluster_1" { label="project: (none)"; + subgraph "cluster_2" { label="environment: (none)"; + subgraph "cluster_3" { label="owner: team-platform"; + "sparkgeo/prescient-infra/stacks/prod:aws_s3_bucket.logs" [label="aws_s3_bucket\nlogs (prescient-infra)", fillcolor="#eeeeee", color="red", penwidth=2]; + } + } + } + subgraph "cluster_4" { label="project: data-lake"; + subgraph "cluster_5" { label="environment: shared"; + subgraph "cluster_6" { label="owner: team-data"; + "sparkgeo/clean-repo/.:google_storage_bucket.lake" [label="google_storage_bucket\nlake (clean-repo)", fillcolor="#cfe2f3"]; + } + } + } + subgraph "cluster_7" { label="project: geo-api"; + subgraph "cluster_8" { label="environment: dev"; + subgraph "cluster_9" { label="owner: bob@sparkgeo.com"; + "sparkgeo/geo-api/Api:AssetsBucket3C4D" [label="AWS::S3::Bucket\nAssetsBucket3C4D (geo-api)", fillcolor="#d9ead3"]; + } + subgraph "cluster_10" { label="owner: team-unknown"; + "sparkgeo/geo-api/Api:ApiFunction1A2B" [label="AWS::Lambda::Function\nApiFunction1A2B (geo-api)", fillcolor="#cfe2f3"]; + } + } + } + subgraph "cluster_11" { label="project: prescient"; + subgraph "cluster_12" { label="environment: prod"; + subgraph "cluster_13" { label="owner: team-platform"; + "sparkgeo/prescient-infra/stacks/prod:aws_s3_bucket.ingest" [label="aws_s3_bucket\ningest (prescient-infra)", fillcolor="#fff2cc"]; + "sparkgeo/prescient-infra/stacks/prod:module.net.aws_vpc.main" [label="aws_vpc\nmain (prescient-infra)", fillcolor="#f4cccc"]; + } + } + } +} diff --git a/tests/asset-register/fixtures/inventories/clean-repo/inventory.json b/tests/asset-register/fixtures/inventories/clean-repo/inventory.json new file mode 100644 index 0000000..39257d1 --- /dev/null +++ b/tests/asset-register/fixtures/inventories/clean-repo/inventory.json @@ -0,0 +1,39 @@ +{ + "generated_at": "2026-10-12T00:30:00Z", + "repo": "sparkgeo/clean-repo", + "stack_kind": "tofu", + "required_tags": [ + "owner", + "project", + "environment", + "data-class" + ], + "counts": { + "resources": 1, + "taggable": 1, + "violations": 0 + }, + "resources": [ + { + "id": "google_storage_bucket.lake", + "type": "google_storage_bucket", + "name": "lake", + "stack": ".", + "account": null, + "region": "us-central1", + "taggable": true, + "tags": { + "owner": "team-data", + "project": "data-lake", + "environment": "shared", + "data-class": "internal" + }, + "tag_violations": [], + "source": { + "repo": "sparkgeo/clean-repo", + "path": null, + "line": null + } + } + ] +} \ No newline at end of file diff --git a/tests/asset-register/fixtures/inventories/geo-api/inventory.json b/tests/asset-register/fixtures/inventories/geo-api/inventory.json new file mode 100644 index 0000000..ae451e3 --- /dev/null +++ b/tests/asset-register/fixtures/inventories/geo-api/inventory.json @@ -0,0 +1,60 @@ +{ + "generated_at": "2026-09-20T05:00:00Z", + "repo": "sparkgeo/geo-api", + "stack_kind": "cdk", + "required_tags": [ + "owner", + "project", + "environment", + "data-class" + ], + "counts": { + "resources": 2, + "taggable": 2, + "violations": 0 + }, + "resources": [ + { + "id": "ApiFunction1A2B", + "type": "AWS::Lambda::Function", + "name": "ApiFunction1A2B", + "stack": "Api", + "account": null, + "region": null, + "taggable": true, + "tags": { + "owner": "team-unknown", + "project": "geo-api", + "environment": "dev", + "data-class": "internal" + }, + "tag_violations": [], + "source": { + "repo": "sparkgeo/geo-api", + "path": "Api/Fn/Resource", + "line": null + } + }, + { + "id": "AssetsBucket3C4D", + "type": "AWS::S3::Bucket", + "name": "AssetsBucket3C4D", + "stack": "Api", + "account": null, + "region": null, + "taggable": true, + "tags": { + "owner": "bob@sparkgeo.com", + "project": "geo-api", + "environment": "dev", + "data-class": "public" + }, + "tag_violations": [], + "source": { + "repo": "sparkgeo/geo-api", + "path": "Api/Assets/Resource", + "line": null + } + } + ] +} \ No newline at end of file diff --git a/tests/asset-register/fixtures/inventories/prescient-infra/inventory.json b/tests/asset-register/fixtures/inventories/prescient-infra/inventory.json new file mode 100644 index 0000000..612e14c --- /dev/null +++ b/tests/asset-register/fixtures/inventories/prescient-infra/inventory.json @@ -0,0 +1,82 @@ +{ + "generated_at": "2026-10-11T05:00:00Z", + "repo": "sparkgeo/prescient-infra", + "stack_kind": "tofu", + "required_tags": [ + "owner", + "project", + "environment", + "data-class" + ], + "counts": { + "resources": 3, + "taggable": 3, + "violations": 1 + }, + "resources": [ + { + "id": "aws_s3_bucket.ingest", + "type": "aws_s3_bucket", + "name": "ingest", + "stack": "stacks/prod", + "account": null, + "region": "us-west-2", + "taggable": true, + "tags": { + "owner": "team-platform", + "project": "prescient", + "environment": "prod", + "data-class": "confidential" + }, + "tag_violations": [], + "source": { + "repo": "sparkgeo/prescient-infra", + "path": null, + "line": null + } + }, + { + "id": "aws_s3_bucket.logs", + "type": "aws_s3_bucket", + "name": "logs", + "stack": "stacks/prod", + "account": null, + "region": "us-west-2", + "taggable": true, + "tags": { + "owner": "team-platform" + }, + "tag_violations": [ + "project", + "environment", + "data-class" + ], + "source": { + "repo": "sparkgeo/prescient-infra", + "path": null, + "line": null + } + }, + { + "id": "module.net.aws_vpc.main", + "type": "aws_vpc", + "name": "main", + "stack": "stacks/prod", + "account": null, + "region": "us-west-2", + "taggable": true, + "tags": { + "owner": "team-platform", + "project": "prescient", + "environment": "prod", + "data-class": "restricted" + }, + "tag_violations": [], + "source": { + "repo": "sparkgeo/prescient-infra", + "path": null, + "line": null + } + } + ] +} \ No newline at end of file diff --git a/tests/asset-register/fixtures/teams.yml b/tests/asset-register/fixtures/teams.yml new file mode 100644 index 0000000..7525000 --- /dev/null +++ b/tests/asset-register/fixtures/teams.yml @@ -0,0 +1,4 @@ +# fixture team list +teams: + - team-platform + - team-data diff --git a/tests/asset-register/run.sh b/tests/asset-register/run.sh new file mode 100755 index 0000000..3e4ed39 --- /dev/null +++ b/tests/asset-register/run.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Checks rollup.py: merge of per-repo inventories, stale detection, unowned +# rules (tag violations, owner outside the team list), the derived markdown +# registers and the DOT snapshot. Needs python3 and jq. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${HERE}/../.." && pwd)" +SCRIPT="${ROOT}/.github/actions/asset-register/rollup.py" +FIX="${HERE}/fixtures" +NOW="2026-10-12T06:00:00Z" +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } +[ -f "${ROOT}/policies/tags/teams.yml" ] || fail "missing policies/tags/teams.yml" + +run() { # <name> <inventories> [args]; sets rc; writes $TMP/<name>/{assets.json,owners.md,unowned.md,data-classes.md,register.dot,summary.md,log,out} + local name="$1" input="$2"; shift 2 + local dir="${TMP}/${name}" + mkdir -p "${dir}" + : > "${dir}/out" + set +e + GITHUB_OUTPUT="${dir}/out" GITHUB_STEP_SUMMARY="${dir}/stepsummary" \ + python3 -I "${SCRIPT}" --inventories "${input}" --output-dir "${dir}" --now "${NOW}" "$@" > "${dir}/log" 2>&1 + rc=$? + set -e +} +reg() { jq -r "$2" "${TMP}/$1/assets.json"; } +output() { grep "^$2=" "${TMP}/$1/out" | cut -d= -f2-; } + +echo "== merge three repos with the team list" +run all "${FIX}/inventories" --teams "${FIX}/teams.yml" --stale-days 7 +[ "${rc}" -eq 0 ] || fail "all rc ${rc}: $(cat "${TMP}/all/log")" +[ "$(reg all '.counts | "\(.repos) \(.assets) \(.unowned) \(.stale_repos)"')" = "3 6 2 1" ] || fail "counts $(reg all .counts)" +[ "$(reg all '.repos[] | "\(.repo) \(.stack_kind) \(.resources) \(.violations) \(.stale)"' | sort)" = "sparkgeo/clean-repo tofu 1 0 false +sparkgeo/geo-api cdk 2 0 true +sparkgeo/prescient-infra tofu 3 1 false" ] || fail "repo rows: $(reg all '.repos')" +[ "$(reg all '.assets[] | "\(.repo) \(.id)"')" = "sparkgeo/clean-repo google_storage_bucket.lake +sparkgeo/geo-api ApiFunction1A2B +sparkgeo/geo-api AssetsBucket3C4D +sparkgeo/prescient-infra aws_s3_bucket.ingest +sparkgeo/prescient-infra aws_s3_bucket.logs +sparkgeo/prescient-infra module.net.aws_vpc.main" ] || fail "assets order" +[ "$(reg all '.assets[] | select(.id == "aws_s3_bucket.logs") | .unowned_reason')" = "missing project, environment, data-class" ] || fail "violation reason" +[ "$(reg all '.assets[] | select(.id == "ApiFunction1A2B") | .unowned_reason')" = "owner team-unknown is not in policies/tags/teams.yml" ] || fail "team reason: $(reg all '.assets[] | select(.id == "ApiFunction1A2B") | .unowned_reason')" +[ "$(reg all '.assets[] | select(.id == "AssetsBucket3C4D") | .unowned_reason')" = "null" ] || fail "email owner must pass" +[ "$(reg all .generated_at)" = "${NOW}" ] || fail "generated_at" +[ "$(reg all .stale_days)" = "7" ] || fail "stale_days" +[ "$(output all repo-count)" = "3" ] || fail "repo-count output" +[ "$(output all unowned-count)" = "2" ] || fail "unowned-count output" +[ "$(output all stale-count)" = "1" ] || fail "stale-count output" +grep -q '^::warning title=asset-register::sparkgeo/geo-api inventory is 22 days old' "${TMP}/all/log" || fail "stale warning: $(cat "${TMP}/all/log")" + +echo "== derived registers" +grep -q '^| team-platform | prescient | prod | 2 |' "${TMP}/all/owners.md" || fail "owners.md row: $(cat "${TMP}/all/owners.md")" +grep -q '^| team-platform | (none) | (none) | 1 |' "${TMP}/all/owners.md" || fail "owners.md untagged row" +grep -q '^| bob@sparkgeo.com | geo-api | dev | 1 |' "${TMP}/all/owners.md" || fail "owners.md email row" +[ "$(grep -c '^| sparkgeo/' "${TMP}/all/unowned.md")" = "2" ] || fail "unowned.md rows: $(cat "${TMP}/all/unowned.md")" +grep -q '^| sparkgeo/geo-api | `ApiFunction1A2B` | team-unknown | owner team-unknown is not in policies/tags/teams.yml |' "${TMP}/all/unowned.md" || fail "unowned.md team row" +[ "$(grep -c '^| sparkgeo/' "${TMP}/all/data-classes.md")" = "2" ] || fail "data-classes.md must list confidential and restricted only: $(cat "${TMP}/all/data-classes.md")" +grep -q '^| sparkgeo/prescient-infra | `module.net.aws_vpc.main` | aws_vpc | restricted | team-platform |' "${TMP}/all/data-classes.md" || fail "data-classes.md row" +grep -q '^## Asset register: 6 assets across 3 repos, 2 unowned, 1 stale inventory' "${TMP}/all/summary.md" || fail "summary heading: $(head -2 "${TMP}/all/summary.md")" +grep -q 'sparkgeo/geo-api' "${TMP}/all/summary.md" || fail "summary names the stale repo" +grep -q 'Asset register' "${TMP}/all/stepsummary" || fail "step summary" +diff -u "${FIX}/expected/register.dot" "${TMP}/all/register.dot" || fail "DOT snapshot differs" +[ -f "${TMP}/all/SHA256SUMS" ] || fail "SHA256SUMS written" +( cd "${TMP}/all" && sha256sum -c --quiet SHA256SUMS ) || fail "SHA256SUMS verifies" + +echo "== no team list: any team-* owner is accepted" +run noteams "${FIX}/inventories" +[ "${rc}" -eq 0 ] || fail "noteams rc ${rc}: $(cat "${TMP}/noteams/log")" +[ "$(reg noteams .counts.unowned)" = "1" ] || fail "noteams unowned $(reg noteams .counts.unowned)" + +echo "== stale-days 30: nothing stale" +run fresh "${FIX}/inventories" --stale-days 30 +[ "$(reg fresh .counts.stale_repos)" = "0" ] || fail "stale-days 30" + +echo "== empty input: empty register, exit 0" +mkdir -p "${TMP}/none" +run empty "${TMP}/none" +[ "${rc}" -eq 0 ] || fail "empty rc ${rc}: $(cat "${TMP}/empty/log")" +[ "$(reg empty '.counts | "\(.repos) \(.assets)"')" = "0 0" ] || fail "empty counts" +grep -q '^::warning title=asset-register::no inventories found' "${TMP}/empty/log" || fail "empty warning" +grep -q '^## Asset register: 0 assets across 0 repos' "${TMP}/empty/summary.md" || fail "empty summary" + +echo "== unreadable inventory is skipped with a warning" +mkdir -p "${TMP}/bad/broken" && echo '{' > "${TMP}/bad/broken/inventory.json" +cp -r "${FIX}/inventories/clean-repo" "${TMP}/bad/" +run bad "${TMP}/bad" +[ "${rc}" -eq 0 ] || fail "bad rc ${rc}" +[ "$(reg bad .counts.repos)" = "1" ] || fail "broken inventory must be skipped" +grep -q '^::warning title=asset-register::broken/inventory.json' "${TMP}/bad/log" || fail "broken warning: $(cat "${TMP}/bad/log")" + +echo "all asset-register checks passed" From ab5bbe94502801b24ce3c222eb532f9dbd51cab2 Mon Sep 17 00:00:00 2001 From: ms280690 <mehul@sparkgeo.com> Date: Sun, 11 Oct 2026 19:27:16 -0700 Subject: [PATCH 2/2] test(#116): fixed-string greps for backticked register rows (SC2016) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPH9145e4gFo6bmXvMgBoF --- tests/asset-register/run.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/asset-register/run.sh b/tests/asset-register/run.sh index 3e4ed39..0e2c39f 100755 --- a/tests/asset-register/run.sh +++ b/tests/asset-register/run.sh @@ -57,9 +57,9 @@ grep -q '^| team-platform | prescient | prod | 2 |' "${TMP}/all/owners.md" || fa grep -q '^| team-platform | (none) | (none) | 1 |' "${TMP}/all/owners.md" || fail "owners.md untagged row" grep -q '^| bob@sparkgeo.com | geo-api | dev | 1 |' "${TMP}/all/owners.md" || fail "owners.md email row" [ "$(grep -c '^| sparkgeo/' "${TMP}/all/unowned.md")" = "2" ] || fail "unowned.md rows: $(cat "${TMP}/all/unowned.md")" -grep -q '^| sparkgeo/geo-api | `ApiFunction1A2B` | team-unknown | owner team-unknown is not in policies/tags/teams.yml |' "${TMP}/all/unowned.md" || fail "unowned.md team row" +grep -qF "| sparkgeo/geo-api | \`ApiFunction1A2B\` | team-unknown | owner team-unknown is not in policies/tags/teams.yml |" "${TMP}/all/unowned.md" || fail "unowned.md team row" [ "$(grep -c '^| sparkgeo/' "${TMP}/all/data-classes.md")" = "2" ] || fail "data-classes.md must list confidential and restricted only: $(cat "${TMP}/all/data-classes.md")" -grep -q '^| sparkgeo/prescient-infra | `module.net.aws_vpc.main` | aws_vpc | restricted | team-platform |' "${TMP}/all/data-classes.md" || fail "data-classes.md row" +grep -qF "| sparkgeo/prescient-infra | \`module.net.aws_vpc.main\` | aws_vpc | restricted | team-platform |" "${TMP}/all/data-classes.md" || fail "data-classes.md row" grep -q '^## Asset register: 6 assets across 3 repos, 2 unowned, 1 stale inventory' "${TMP}/all/summary.md" || fail "summary heading: $(head -2 "${TMP}/all/summary.md")" grep -q 'sparkgeo/geo-api' "${TMP}/all/summary.md" || fail "summary names the stale repo" grep -q 'Asset register' "${TMP}/all/stepsummary" || fail "step summary"