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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 80 additions & 15 deletions .github/workflows/sbom-inventory-scheduler.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
# Central SBOM inventory aggregator.
#
# Scheduled companion to sbom-generation.yml. It reads every managed repo's
# Hourly companion to sbom-generation.yml. It reads every non-fork repository's
# latest SBOM back out of the GitHub dependency graph (populated by the
# per-repo SBOM Generation dependency snapshot) and writes ONE consolidated org
# inventory into this .github repo:
#
# docs/sbom/inventory.json machine-readable component roll-up
# docs/sbom/inventory.md component + license roll-up (flags copyleft /
# NOASSERTION against the commercial-license-only policy)
# docs/sbom/inventory.md component + license roll-up for commercial-policy review
#
# Cross-repo reads reuse the OpenCode app OIDC token exchange the other
# schedulers use, falling back to github.token. Results land through a PR so the
# central inventory update follows the same review path as everything else.
# Cross-repo reads require the OpenCode app OIDC token exchange or the dedicated
# organization-wide SBOM token. A repository-scoped github.token is deliberately
# not a fallback because a partial private-repository view must never publish as
# a complete organization inventory. Results land through a PR so the central
# inventory update follows the same review path as everything else.
name: SBOM Inventory Scheduler

on:
schedule:
- cron: "0 6 * * 1"
- cron: "0 * * * *"
repository_dispatch:
types: [sbom-inventory]

Expand Down Expand Up @@ -103,12 +104,23 @@ jobs:
echo "token=$app_token"
} >>"$GITHUB_OUTPUT"

- name: Require organization-wide SBOM credential
env:
GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "Organization-wide SBOM credential unavailable; refusing partial inventory." >&2
exit 1
fi
echo "::add-mask::$GH_TOKEN"
Comment thread
seonghobae marked this conversation as resolved.

- name: Checkout trusted aggregator
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ContextualWisdomLab/.github
ref: main
fetch-depth: 1
fetch-depth: 0
persist-credentials: false

- name: Set up Python
Expand All @@ -119,37 +131,90 @@ jobs:
- name: Self-test aggregator
run: python3 scripts/ci/sbom_inventory_aggregator.py --self-test

- name: Discover live non-fork repositories
env:
GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }}
run: |
set -euo pipefail
repos_json="$(
gh repo list \
--no-archived \
--limit 500 \
--json "nameWithOwner,isFork" \
-- \
"$ORG_LOGIN"
)"
mapfile -t repos < <(
jq -r '.[] | select(.isFork == false) | .nameWithOwner' <<<"$repos_json"
)
if [ "${#repos[@]}" -eq 0 ]; then
echo "No live non-fork repositories were discovered for $ORG_LOGIN." >&2
exit 1
fi
printf '%s\n' "${repos[@]}" >"$RUNNER_TEMP/cwl-nonfork-repositories.txt"
echo "Discovered ${#repos[@]} live non-fork repositories."

- name: Aggregate org SBOM inventory
env:
GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token || github.token }}
GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }}
run: |
set -euo pipefail
repo_args=()
while IFS= read -r repo; do
if [ -n "$repo" ]; then
repo_args+=(--repo "$repo")
fi
done <"$RUNNER_TEMP/cwl-nonfork-repositories.txt"
if [ "${#repo_args[@]}" -eq 0 ]; then
echo "Non-fork repository evidence file was empty." >&2
exit 1
fi
generated_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
python3 scripts/ci/sbom_inventory_aggregator.py \
--org "$ORG_LOGIN" \
--output-dir docs/sbom \
--generated-at "$generated_at"
--generated-at "$generated_at" \
"${repo_args[@]}"

- name: Open or update inventory PR
env:
GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token || github.token }}
GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }}
run: |
set -euo pipefail
if git diff --quiet -- docs/sbom; then
echo "No SBOM inventory changes; nothing to publish."
exit 0
fi

branch="automation/sbom-inventory"
git config user.name "cwl-sbom-inventory[bot]"
git config user.email "cwl-sbom-inventory@users.noreply.github.com"
git checkout -B "$branch"
git add docs/sbom
git commit -m "chore: refresh org SBOM inventory"
git push --force-with-lease origin "$branch"

# persist-credentials remains false; configure Git's credential helper
# from the already masked GH_TOKEN without putting the token in a URL.
gh auth setup-git
Comment thread
seonghobae marked this conversation as resolved.

# Preserve the existing publication head as ancestry without trusting
# its generated tree. A concurrent writer makes the final normal push
# fail closed instead of rewriting remote history.
if git ls-remote --exit-code --heads origin "refs/heads/$branch" >/dev/null 2>&1; then
git fetch --no-tags origin "refs/heads/$branch"
previous_head="$(git rev-parse FETCH_HEAD)"
if ! git merge-base --is-ancestor "$previous_head" HEAD; then
git merge \
--strategy=ours \
--no-edit \
-m "chore: preserve SBOM inventory publication lineage" \
"$previous_head"
fi
fi

git push origin "HEAD:refs/heads/$branch"
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then
gh pr create \
--base main \
--head "$branch" \
--title "chore: refresh org SBOM inventory" \
--body "Automated central SBOM inventory refresh. Review the license roll-up in docs/sbom/inventory.md for any flagged copyleft/NOASSERTION components."
--body "Automated central SBOM inventory refresh for live non-fork repositories. Review reciprocal, restricted, and NOASSERTION license evidence in docs/sbom/inventory.md against the product's actual distribution and hosted-service model."
fi
45 changes: 45 additions & 0 deletions docs/doctoring/hourly-commercial-license-sbom-remediation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Hourly commercial-license SBOM remediation

Status: implementation evidence for the central ContextualWisdomLab supply-chain control plane.
Scope: live repositories whose GitHub metadata proves `fork=false`; forks are provenance evidence only and are never owner-side remediation targets.

## Observed gap

At `ContextualWisdomLab/.github@5f81d8e665b7d3f51f379a090e077486dbf548c5`, the central SBOM inventory still reports `pending first scheduled run`, zero repositories, and zero components. The scheduler runs only once a week and delegates organization discovery to an aggregator that does not itself exclude forks on protected `main`. That combination can make a zero-finding report look materially cleaner than the evidence actually supports.

The existing license classifier is intentionally high-recall but is not a legal conclusion: it substring-flags GPL/AGPL/LGPL/MPL/EPL/CDDL and related expressions plus `NOASSERTION`. A flagged component therefore means **commercial-policy review is required**, not “commercial use is forbidden.” The GNU GPL explicitly permits selling copies; obligations depend on how covered code is combined, modified, conveyed, or offered as a network service. AGPLv3 adds a corresponding-source obligation for users interacting remotely with a modified covered program under section 13.

## Decision

1. Refresh the organization inventory every hour.
2. Build the owned target set from live GitHub repository metadata and admit only entries with `isFork == false` before any SBOM collection.
3. Require an organization-wide SBOM credential before discovery or collection. The repository-scoped `github.token` is not an acceptable fallback because it can silently hide private sibling repositories; absence of the dedicated token or successful OpenCode app exchange fails closed instead of publishing a partial inventory.
4. Reconcile SPDX/CycloneDX evidence with manifests, lockfiles, vendored/native/binary assets, container inputs, generated packages, and dependency-graph evidence before calling an inventory complete.
5. Interpret license expressions as evidence requiring an explicit `allow`, `review`, or `replace/block` outcome tied to the actual product distribution and hosted-service model. Do not equate copyleft with non-commercial use.
6. For an actionable incompatibility, remediate in this order: remove an unused component; replace it with a maintained permissively licensed equivalent; implement only the bounded required capability cleanly in-house from independent product/API/standards behavior; isolate it behind an independently deployed service/process boundary only when that genuinely changes the technical and legal coupling; or redesign the feature to remove the dependency.
7. A replacement implementation must not copy protected source, tests, comments, data, expressive structure, or other copyrightable material from the incompatible implementation. Product contracts, published standards, independent interoperability documentation, and lawful black-box behavior are the acceptable specification sources.
8. Update manifests and lockfiles, SBOMs, NOTICE/THIRD_PARTY_NOTICES, tests, architecture/ADR evidence, CHANGELOG when release-relevant, and `docs/product-technical-gap-baseline.md`; then rerun exact-head Checks/reviews and merge only through ordinary branch protection.
9. Preserve concurrent writers. The recurring inventory publication branch must advance without history rewriting; a race fails closed and is retried on a later run. Because checkout deliberately keeps `persist-credentials: false`, publication establishes Git authentication through the masked organization-wide `GH_TOKEN` with `gh auth setup-git` before the first remote Git operation.

## Standards and interpretation baseline

- SPDX 3.0 is the current SPDX document specification; SPDX is standardized as ISO/IEC 5962:2021. SBOM license identifiers and expressions are machine contracts and must not be reduced to free-text substring heuristics for final policy decisions.
- CycloneDX 1.7 is the current stable BOM specification and ECMA-424 2nd Edition. CycloneDX 2.0 is announced for 2026 but is not yet the stable baseline as of 2026-09-01.
- GPL-family software can be used commercially. The engineering concern for ContextualWisdomLab is whether the concrete incorporation, modification, conveyance, hosted-service behavior, source-offer obligation, attribution, patent terms, or reciprocal scope conflicts with the intended proprietary/commercial product contract.
- Unknown (`NOASSERTION`/unlicensed) and explicitly non-commercial, evaluation-only, field-of-use, or source-available restrictions fail closed into review until provenance and rights are established.

This is an engineering governance policy and evidence record, not legal advice. Ambiguous rights or license compatibility that cannot be resolved from authoritative terms remains a legal-rights blocker rather than being guessed by automation.

## Verification contract

The scheduler contract is executable in `tests/test_sbom_inventory_scheduler_contract.py`: it binds assertions to the named executable discovery, aggregation, credential, and publication steps; requires an hourly cron; requires live `isFork == false` filtering; passes only the verified repositories explicitly to the aggregator; rejects `github.token` fallback; configures authenticated Git before remote publication; and prohibits force-push behavior. The first inventory run after merge is not considered complete merely because it reports zero findings; unavailable SBOMs and incomplete dependency materialization remain explicit defects to repair.

## References

Free Software Foundation. (n.d.). *Frequently asked questions about the GNU licenses*. https://www.gnu.org/licenses/gpl-faq.html

Free Software Foundation. (2007). *GNU Affero General Public License, version 3*. https://www.gnu.org/licenses/agpl-3.0.html

OWASP Foundation. (2025). *CycloneDX specification 1.7 (ECMA-424, 2nd ed.)*. https://cyclonedx.org/specification/overview/

SPDX Workgroup. (n.d.). *SPDX specifications*. Linux Foundation. https://spdx.dev/use/specifications/
72 changes: 72 additions & 0 deletions tests/test_sbom_inventory_scheduler_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Executable contract for the central SBOM inventory scheduler."""

from pathlib import Path


WORKFLOW = Path(".github/workflows/sbom-inventory-scheduler.yml")


def _workflow_text() -> str:
"""Return the scheduler source as text for dependency-free contract checks."""
return WORKFLOW.read_text(encoding="utf-8")


def _step_body(name: str) -> str:
"""Return one named executable workflow step, excluding later steps."""
workflow = _workflow_text()
marker = f" - name: {name}\n"
start = workflow.index(marker)
next_step = workflow.find("\n - name: ", start + len(marker))
return workflow[start : next_step if next_step != -1 else len(workflow)]


def test_sbom_inventory_scheduler_runs_hourly() -> None:
"""Organization license evidence must refresh once each hour."""
workflow = _workflow_text()
assert 'cron: "0 * * * *"' in workflow
assert 'cron: "0 6 * * 1"' not in workflow


def test_sbom_inventory_scheduler_requires_cross_repo_credential() -> None:
"""Repository-scoped github.token must never publish a partial org inventory."""
workflow = _workflow_text()
credential_step = _step_body("Require organization-wide SBOM credential")
assert "|| github.token" not in workflow
assert (
"GH_TOKEN: ${{ secrets.SBOM_INVENTORY_TOKEN || steps.aggregator_app_token.outputs.token }}"
in credential_step
)
assert 'if [ -z "${GH_TOKEN:-}" ]; then' in credential_step
assert "refusing partial inventory" in credential_step
assert "exit 1" in credential_step


def test_sbom_inventory_scheduler_excludes_forks_before_collection() -> None:
"""Only repositories proven non-forks may become owned inventory targets."""
discovery_step = _step_body("Discover live non-fork repositories")
aggregation_step = _step_body("Aggregate org SBOM inventory")
assert "gh repo list" in discovery_step
assert '"nameWithOwner,isFork"' in discovery_step
assert ".[] | select(.isFork == false) | .nameWithOwner" in discovery_step
assert "cwl-nonfork-repositories.txt" in discovery_step
assert 'repo_args+=(--repo "$repo")' in aggregation_step
assert '"${repo_args[@]}"' in aggregation_step
assert '--org "$ORG_LOGIN"' not in aggregation_step


def test_sbom_inventory_scheduler_authenticates_git_before_publication() -> None:
"""The non-persistent checkout must establish Git auth before remote mutation."""
publication_step = _step_body("Open or update inventory PR")
auth_index = publication_step.index("gh auth setup-git")
first_remote_index = min(
publication_step.index("git ls-remote"),
publication_step.index("git push"),
)
assert auth_index < first_remote_index


def test_sbom_inventory_scheduler_does_not_force_push() -> None:
"""Recurring publication must preserve concurrent branch history."""
publication_step = _step_body("Open or update inventory PR")
assert "--force" not in publication_step
assert "--force-with-lease" not in publication_step
Loading