Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bbd7fa9
feat(actions): inventory orphaned workflow identities
seonghobae Aug 16, 2026
1e84d65
Merge branch 'main' into feat/orphaned-workflow-lifecycle-inventory
opencode-agent[bot] Aug 20, 2026
e7a6cab
Merge branch 'main' into feat/orphaned-workflow-lifecycle-inventory
opencode-agent[bot] Aug 20, 2026
8d08bac
Merge branch 'main' into feat/orphaned-workflow-lifecycle-inventory
opencode-agent[bot] Aug 20, 2026
271ad3a
docs: keep workflow caller note with repair gate
seonghobae Aug 21, 2026
ab51f48
fix: classify workflow ledger write failures
seonghobae Aug 21, 2026
e281305
fix(actions): route fleet orphan findings to owners
seonghobae Aug 21, 2026
580c8e9
style(actions): lint lifecycle inventory
seonghobae Aug 21, 2026
30644f0
Merge remote-tracking branch 'refs/remotes/origin/main' into HEAD
seonghobae Aug 21, 2026
442ba6e
fix: complete orphan workflow owner routing
seonghobae Aug 21, 2026
8d141d5
docs: clarify workflow path validation
seonghobae Aug 21, 2026
84b84ae
fix(actions): bind workflow inventory to consumed pages
seonghobae Aug 21, 2026
ef77ced
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 21, 2026
71c0cc8
fix: fail closed on partial workflow inventories
seonghobae Aug 21, 2026
3710efc
Merge branch 'main' into feat/orphaned-workflow-lifecycle-inventory
opencode-agent[bot] Aug 21, 2026
d70388a
Merge branch 'main' into feat/orphaned-workflow-lifecycle-inventory
opencode-agent[bot] Aug 21, 2026
740379a
test: align scheduler contract and audit runtime
seonghobae Aug 21, 2026
1be7698
Merge protected main into orphaned workflow inventory
seonghobae Aug 21, 2026
f1344a8
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Sep 1, 2026
8e24b8b
fix: collect live workflow lifecycle evidence
seonghobae Sep 1, 2026
9edc4e1
test: enforce workflow lifecycle prevention contract
seonghobae Sep 1, 2026
5b2b495
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Sep 1, 2026
63f4e43
test: close scheduler coverage gaps
seonghobae Sep 1, 2026
4aedb7e
fix(inventory): prove complete live fleet evidence
seonghobae Sep 1, 2026
2cbd370
Merge remote-tracking branch 'origin/main' into merge-main-into-1026
claude Sep 4, 2026
d17ff23
merge: main@f2f91b80 into #1026 (feat/orphaned-workflow-lifecycle-inv…
seonghobae Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/workflow-lifecycle-inventory-quality-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Workflow Lifecycle Inventory Quality CI

on:
pull_request:
branches: [main]
paths:
- ".github/workflows/workflow-lifecycle-inventory.yml"
- ".github/workflows/workflow-lifecycle-inventory-quality-ci.yml"
- "scripts/ci/inventory_orphaned_workflows.py"
- "tests/test_inventory_orphaned_workflows.py"
- "tests/test_workflow_lifecycle_inventory_workflow.py"
- "docs/doctoring/orphaned-workflow-lifecycle.md"
- "ARCHITECTURE.md"
- "CHANGELOG.md"

permissions:
contents: read

jobs:
exact-head-quality:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout exact pull-request head
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"

- name: Install hash-verified test tools
shell: bash --noprofile --norc -e -o pipefail {0}
run: |
python -m pip install --only-binary=:all: --require-hashes -r <(cat <<'EOF'
coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f
iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e
pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
EOF
)

- name: Prove exact-head behavior and branch coverage
shell: bash --noprofile --norc -e -o pipefail {0}
run: |
test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}"
coverage run --branch -m pytest --import-mode=importlib \
tests/test_inventory_orphaned_workflows.py \
tests/test_workflow_lifecycle_inventory_workflow.py -q
coverage report --include='scripts/ci/inventory_orphaned_workflows.py' --show-missing --fail-under=100
python -m compileall -q scripts/ci/inventory_orphaned_workflows.py tests/test_inventory_orphaned_workflows.py
git diff --exit-code
77 changes: 77 additions & 0 deletions .github/workflows/workflow-lifecycle-inventory.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Workflow Lifecycle Inventory

on:
schedule:
- cron: "31 4 * * *"

concurrency:
group: workflow-lifecycle-inventory
cancel-in-progress: false

permissions:
contents: read
actions: read

jobs:
inventory:
if: >-
github.repository == 'ContextualWisdomLab/.github' &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.13.2
with:
egress-policy: block
allowed-endpoints: >-
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
results-receiver.actions.githubusercontent.com:443
*.actions.githubusercontent.com:443
*.blob.core.windows.net:443

- name: Checkout exact trusted inventory source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"

- name: Collect read-only organization evidence
env:
GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }}
shell: bash --noprofile --norc -e -o pipefail {0}
run: |
if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::PR_REVIEW_MERGE_TOKEN is required for complete organization visibility."
exit 1
fi
echo "::add-mask::$GH_TOKEN"
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
python scripts/ci/inventory_orphaned_workflows.py --live \
--output "$RUNNER_TEMP/workflow-lifecycle-ledger.json" \
--receipt-output "$RUNNER_TEMP/workflow-lifecycle-api-receipts.json" \
--failure-output "$RUNNER_TEMP/workflow-lifecycle-failure.json"
python -m json.tool "$RUNNER_TEMP/workflow-lifecycle-ledger.json" >/dev/null
python -m json.tool "$RUNNER_TEMP/workflow-lifecycle-api-receipts.json" >/dev/null

- name: Preserve immutable read-only evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: workflow-lifecycle-inventory-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ runner.temp }}/workflow-lifecycle-ledger.json
${{ runner.temp }}/workflow-lifecycle-api-receipts.json
${{ runner.temp }}/workflow-lifecycle-failure.json
if-no-files-found: error
Comment thread
seonghobae marked this conversation as resolved.
retention-days: 30
12 changes: 12 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ Product callers stagger Clearfolio at minute 23, DiskSage at minute 37, and
fast-mlsirm at minute 49. Each caller is read-only, dispatches at most one
repair, and delegates all privileged logic to the same sealed scheduler.

## Workflow lifecycle inventory

GitHub persists Actions registry identities independently of the protected
default-branch tree. `scripts/ci/inventory_orphaned_workflows.py` is a
read-only classifier and live collector: it paginates the organization and
registry, binds each advertised workflow to a revalidated default-branch SHA,
distinguishes repository YAML from GitHub-owned `dynamic/` identities, and
fail-closes on incomplete trees, pagination, permissions, or visibility. The
scheduled integration retains content-bound API receipts. Classification never
disables or recreates workflows; the operator primitive is separately reviewed
and accepts only an immutable orphan ledger record on an unchanged head.

## Exact-artifact SBOM attestation

```mermaid
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,7 @@ Semantic Versioning where the repository publishes a release.
- Added a dedicated DiskSage hourly caller that invokes the same product-neutral RCA and remediation-feasibility scheduler with an exact repository target, one-dispatch budget, two-hour same-head retry floor, non-cancelling single-flight heartbeat, and explicit established scheduler credentials.
- Added a dedicated fast-mlsirm hourly caller that preserves Rust-owned psychometric arithmetic while dispatching at most one exact-head, root-cause-driven repair with a two-hour same-head retry floor.
- Added a dedicated Orgmetra hourly caller at minute 58 that targets protected `develop`, dispatches at most one exact-head repair, preserves a two-hour same-head retry floor and non-cancelling single-flight execution, and maps only the established scheduler credentials.
- Added read-only orphan-workflow lifecycle classification with exact default-branch binding, fail-closed visibility and pagination checks, explicit case-insensitive owner-issue routing for active and disabled orphans, and no registry mutation authority.

### Changed

Expand All @@ -1281,6 +1282,18 @@ Semantic Versioning where the repository publishes a release.

### Fixed

- Require orphan-workflow lifecycle fixtures to prove complete organization
repository visibility before emitting an audit ledger, preventing partial
inventories from overstating fleet coverage. Live collection now verifies
authenticated organization-wide repository totals, retries one explicit
HTTP 5xx once, rejects every malformed Git tree member, preserves partial
read receipts and structured failure evidence, gates completeness before
classification, and isolates disable/issue writes in the operator module.
- Add a live, paginated organization workflow inventory with content-bound API
receipts, exact default-head revalidation, and a separately reviewed,
ledger-bound operator disable primitive.
- Schedule the central read-only workflow-lifecycle sweep on the protected
default branch and retain its exact ledger and API receipts for 30 days.
- Prefer the job-scoped `github.token` when the central OpenCode dispatch
publishes a commit status back to the same `.github` repository. The job's
declared `statuses: write` permission now reaches the endpoint instead of an
Expand Down
127 changes: 127 additions & 0 deletions docs/doctoring/orphaned-workflow-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Orphaned GitHub Actions workflow-lifecycle inventory

검토 기준일: **2026-08-16**

## Incident

Live Actions inventories showed the same recurrence in multiple
ContextualWisdomLab repositories (ContextualWisdomLab/.github#945):

- AppGuardrail advertised dozens of historical `apply-*`, `finalize-*`,
and `*-once.yml` identities as `state: active` while sampled default-branch
paths returned 404 (ContextualWisdomLab/appguardrail#929);
- Clearfolio retained `one-shot-*` and PR-specific repair identities after
the YAML had left the protected default branch (ContextualWisdomLab/clearfolio#423);
- DiskSage retained PR-specific finalizers in the same shape
(ContextualWisdomLab/disksage#191).

Source deletion is not a complete workflow lifecycle. GitHub persists
registry records independently of the default-branch tree, so a buyer or
reviewer cannot treat "the YAML is gone" as "no writer remains enabled."

## Decision

1. The central `.github` repository owns a **read-only** inventory that
binds every advertised workflow identity to the exact protected
default-branch SHA observed at the start and re-read at the end.
2. Classification is evidence-based: `present_active`, `present_disabled`,
`orphan_active`, `orphan_disabled`, `dynamic_owned`, or `unresolved`.
A file named `once` is not alone proof of invalidity. A benign name
does not hide a missing source file.
3. Incomplete visibility (401/403/404), a 5xx after one retry, pagination
truncation, `total_count` drift, reused workflow IDs, percent-encoded
paths, and default-branch movement fail closed.
4. This scanner never disables, deletes, or recreates workflows. Disablement
remains a separately reviewed operator step after the ledger is
revalidated.
5. `NVIDIA_NIM_API_KEY` may exist elsewhere in the control plane. This
inventory never reads `COPILOT_GITHUB_TOKEN`.
6. CSAP and SOC 2 are design constraints (access visibility, change
management, evidence retention). This record is not a certification
claim. Operational identities (repository, workflow path, workflow ID)
are not masked as PII.
7. Confirmed repository owner routes are maintained as an explicit,
linkable registry from live fleet evidence. Repository slugs are matched
case-insensitively, and both `orphan_active` and `orphan_disabled`
classifications retain the route. The scanner does not infer issue
numbers, create issues, or convert an absent owner route into a passing
result.

## Trust boundary

The production CLI uses `--live` with the established central `GH_TOKEN`
transport. It paginates all visible repositories and workflows, rejects a
truncated recursive tree, and re-reads each default-branch head. A mandatory
API receipt file content-binds every read. Fixture input remains available for
deterministic tests. Neither boundary receives `secrets: inherit` or a guessed PAT.
GitHub-owned `dynamic/` identities are never treated as deleted repository
files.

The ledger improves operational visibility into enabled control-plane writers;
that visibility gap is not itself CWE-200 sensitive-information exposure.
CWE-862 describes missing authorization when a registry mutation is performed
without a reviewed operator path. This increment closes the visibility gap and
refuses the mutation.

## Operator contract

For a live read-only sweep, run:

```bash
python3 scripts/ci/inventory_orphaned_workflows.py --live \
--output /tmp/workflow-lifecycle-ledger.json \
--receipt-output /tmp/workflow-lifecycle-api-receipts.json \
--failure-output /tmp/workflow-lifecycle-failure.json
```

The protected-default-branch integration is
`.github/workflows/workflow-lifecycle-inventory.yml`. Its scheduled runs have
read-only repository permissions, verify the checked-out SHA, and
retain completed API receipts plus either the immutable ledger or structured
failure evidence for 30 days. The live collector proves fleet completeness by
matching the paginated repository list to authenticated organization-wide
public/private totals; pagination alone is not accepted. It contains no disable endpoint;
operator mutation remains a later reviewed action.

For fixture verification, feed a JSON payload with `organization`, `observed_at`,
`repository_inventory_complete: true`, and one object per visible non-archived
repository. The completeness flag is mandatory: a partial repository list must
fail closed instead of producing a ledger that overstates fleet coverage. Each
repository must include the
start and end default-branch SHAs, the exact tree paths at that SHA, and
complete workflow pages (`total_count`, `workflows`, and either `_link_next`
or a GitHub `Link` header). Archived repositories are skipped.

```bash
python3 scripts/ci/inventory_orphaned_workflows.py \
--payload schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json \
--output /tmp/workflow-lifecycle-ledger.json
```

The scanner never exports a write primitive. The separately reviewed
`scripts/ci/workflow_lifecycle_operator.py` module's
`disable_confirmed_orphan` primitive accepts only an immutable `orphan_active` record and an identical
fresh head SHA, then addresses its exact numeric workflow ID. After a reviewed
operator pass, rerun the organization sweep and retain both receipt sets.
Known AppGuardrail, Clearfolio, and DiskSage owner routes bind the same live
evidence to their governance issues without heuristic issue creation.

## Rollback

Rollback removes the inventory script, focused tests, schema example,
architecture entry, changelog entry, and this doctoring record together. No
registry state is mutated, so rollback does not re-enable or disable workflows.

## References

GitHub. (2026). *REST API endpoints for workflows*. GitHub Docs.
https://docs.github.com/en/rest/actions/workflows

GitHub. (2026). *Security hardening for GitHub Actions*. GitHub Docs.
https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions

MITRE. (2026a). *CWE-200: Exposure of sensitive information to an unauthorized actor*.
https://cwe.mitre.org/data/definitions/200.html

MITRE. (2026b). *CWE-862: Missing authorization*.
https://cwe.mitre.org/data/definitions/862.html
41 changes: 41 additions & 0 deletions schemas/examples/cwl-workflow-lifecycle-ledger-v1.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"organization": "ContextualWisdomLab",
"observed_at": "2026-08-16T12:00:00Z",
"repository_inventory_complete": true,
"repositories": [
{
"name": "appguardrail",
"archived": false,
"default_branch": "main",
"default_branch_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"default_branch_sha_after": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"tree_paths": [".github/workflows/ci.yml"],
"workflow_pages": [
{
"total_count": 3,
"workflows": [
{
"id": 11,
"name": "finalize-once",
"path": ".github/workflows/finalize-once.yml",
"state": "active"
},
{
"id": 12,
"name": "ci",
"path": ".github/workflows/ci.yml",
"state": "active"
},
{
"id": 13,
"name": "pages",
"path": "dynamic/pages/pages-build-deployment",
"state": "active"
}
],
"_link_next": false
}
]
}
]
}
Loading
Loading