Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
bb11a19
Merge remote-tracking branch 'origin/main' into fleet-incident-govern…
seonghobae Sep 24, 2026
c2a2430
fix(actions): bind lifecycle inventory to stable fleet evidence
seonghobae Sep 24, 2026
2696165
experiment: revalidate owner issue evidence before publication
seonghobae Sep 24, 2026
48abe0e
docs: require live proof before owner issue publication
seonghobae Sep 24, 2026
19ff8c8
docs: define temporary workflow lifecycle contract
seonghobae Sep 24, 2026
e0c860c
feat: bind owner issue publication to reviewed ledger
seonghobae Sep 24, 2026
bf01048
fix: preserve lifecycle evidence and isolate owner updates
seonghobae Sep 24, 2026
1c52305
test: repair Strix gate baseline fixtures
seonghobae Sep 24, 2026
54033b9
test: assert REST workflow pagination requests
seonghobae Sep 27, 2026
70a9dd3
merge: integrate Strix baseline fixture repair into issue 945
seonghobae Sep 27, 2026
e794828
merge: integrate CI foundation repairs into issue 945
seonghobae Sep 27, 2026
a75be4d
merge: integrate scoped Noema continuation repair into issue 945
seonghobae Sep 27, 2026
354951e
merge: adopt deployed central self-hosted runner routing
seonghobae Sep 27, 2026
01fd22f
Merge current main to repair protected review conflicts
seonghobae Sep 27, 2026
2e6694d
fix(lifecycle): bind owner issue reuse to trusted authors
seonghobae Sep 27, 2026
2441fe3
Merge canonical CodeQL runner contract repair
seonghobae Sep 27, 2026
4423628
merge: adopt current central CI and trusted Strix fixtures
seonghobae Sep 27, 2026
03bbd9b
merge: integrate canonical dispatch blob pin repair
seonghobae Sep 27, 2026
2030cbd
Merge main into workflow lifecycle governance
seonghobae Sep 28, 2026
15a62d0
fix(lifecycle): group orphan owner issues by repository
seonghobae Sep 28, 2026
117a5b3
fix(lifecycle): reuse verified fleet owner issues
seonghobae Sep 28, 2026
7e8106e
fix(actions): guard branch-owned workflow identities before retirement
seonghobae Sep 28, 2026
6f645a7
test(lifecycle): cover owner-issue operator fail-closed paths
seonghobae Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ concurrency:
github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule) ||
github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != '' && format('target-{0}-pr-{1}', github.event.client_payload.target_repository, github.event.client_payload.pr_number) ||
github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && format('pr-{0}', github.event.client_payload.pr_number) ||
github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository) ||
github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.event.client_payload.target_repository || github.repository) ||
github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }}

Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/workflow-lifecycle-inventory-quality-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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"
- "scripts/ci/workflow_lifecycle_operator.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,scripts/ci/workflow_lifecycle_operator.py' --show-missing --fail-under=100
python -m compileall -q scripts/ci/inventory_orphaned_workflows.py scripts/ci/workflow_lifecycle_operator.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 @@ -1399,6 +1399,12 @@ 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.
- Add a live, paginated organization workflow inventory with content-bound API
receipts, exact default-head revalidation, and a separately reviewed,
ledger-bound owner-issue publisher that never disables workflows.
- Schedule the central read-only workflow-lifecycle sweep on the protected
default branch and retain its exact ledger and API receipts for 30 days.

### Changed

Expand All @@ -1425,6 +1431,13 @@ 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 owner-issue writes in the operator module.
- 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
180 changes: 180 additions & 0 deletions docs/doctoring/orphaned-workflow-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# 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. `orphan_active` means absent from the
bound default-branch tree; an open pull request can still use that path on
its head or base. It is not a safe-disable verdict.
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.

## Prevention contract for repository workflows

A bounded repair workflow must take one of two paths before its PR is merged:

1. Keep the workflow on a short-lived branch, complete its work through a normal
PR, and remove the YAML before it reaches the protected default branch. It
never acquires a default-branch workflow registry identity.
2. If the workflow must reach the protected default branch, record its lifecycle
owner and expiry condition in the PR. The owner must arrange a separately
reviewed, exact-ID registry-disable action after use, then retain the API
receipt and a fresh inventory showing the identity disabled.

Deleting the YAML alone does not satisfy the second path. A later inventory
finding remains open until the owner either completes that disablement or
documents an explicit reviewed exception. Neither a workflow name nor a
missing source file authorizes disablement by the read-only scanner.

## 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 and owner-issue publisher expose no workflow-state mutation.
Disablement needs a separate reviewed operator path that rechecks the live
default-branch SHA, exact workflow ID, source absence, open pull request heads
and bases, owner intent, active runs, and reusable-workflow callers immediately
before the API write. 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.
The separately invoked owner-issue publisher verifies that each finding belongs
to the supplied ledger, then rechecks the live repository, default-branch SHA,
workflow ID/path/state, and complete source tree before posting. A changed
identity or restored source file stops publication. The publisher computes the
ledger's digest and scans existing owner issues before creating one for a
repository without a known route. It does not run in the read-only inventory
job.
For a repository without a known route, one trusted owner issue groups its
findings. Each revalidated workflow ID and path gets its own evidence comment.
Before posting, the publisher checks the open issue and every comment for the
exact evidence body, so retrying the same ledger does not duplicate a comment
or hide another workflow from that ledger.

For one separately reviewed finding, the operator supplies the exact ledger
digest, repository, and workflow ID:

```bash
python3 -m scripts.ci.workflow_lifecycle_operator \
--ledger /tmp/workflow-lifecycle-ledger.json \
--expected-ledger-sha256 "$REVIEWED_LEDGER_SHA256" \
--repository appguardrail --workflow-id "$REVIEWED_WORKFLOW_ID"
```

The digest binds the reviewed file; it is not approval or fleet-completeness
proof by itself. Review the ledger and its API receipts first. The command
accepts one identity, requires the canonical ledger and its completeness
marker, and reads live GitHub evidence again before its issue write. It never
disables a workflow. Existing issue markers are reused only for repository
owners, members, collaborators, or the established github-actions and
opencode-agent bot publishers. Other public issue authors cannot redirect
evidence or poison the duplicate-issue check.

## Rollback

Rollback removes the inventory and owner-issue operator scripts, focused tests,
schema example, architecture entry, changelog entry, this doctoring record,
and both `.github/workflows/workflow-lifecycle-inventory.yml` and
`.github/workflows/workflow-lifecycle-inventory-quality-ci.yml` 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
Loading
Loading