Repository navigation
drift detection
Issue #12 adds the exit-code contract, plan --ci as a drift check for scheduled workflows, and apply --force-secrets for credential rotation without a file edit. docs/design.md (## Drift detection and CI, and the exit-code paragraph) already specifies the intended behavior correctly; this plan is about closing the gap between that specification and the current, unimplemented CLI.
Completed 2026-09-10. Issue #28, Issue #29, and Issue #30 are closed, and every behavior described below ships. The rest of this page is kept as the plan as written on 2026-09-08.
One deviation from the scope boundary at the end of this page: Issue #30 also corrected
docs/design.md and docs/design_ja.md. Their drift-detection prose said plan --ci
"exits 2 when any change would be made", which contradicts the secret exclusion this
plan introduced and the blocked-plan precedence in planOutcome. The correction and its
reason are recorded as ADR record 7. The outcome table below now also lives in
design.md as the normative record. The shipped predicate is Plan.HasDrift(), backed
by Plan.DriftCount(), which plan --ci uses for the change count in its drift notice.
| Type | Issue |
|---|---|
| Parent | Issue #12 |
Exit-code contract and plan --ci
|
Issue #28 |
apply --force-secrets |
Issue #29 |
| Documentation and example workflows | Issue #30 |
None of the five acceptance criteria in Issue #12 are met today: plan has no --ci flag, cmd/gh-repospec/main.go maps every error to exit code 1 unconditionally, apply has no --force-secrets flag, the example drift-detection workflow documents the pre-feature behavior, and the README status note still lists this work as unimplemented (alongside import, which has since shipped).
Reviewing the specification against the planner found one thing the literal issue text does not account for: appendSecretChange reports every declared secret whose source environment variable is set as an unconditional update, because GitHub never returns a secret value. The existing HasChanges() predicate already includes these. Wiring plan --ci straight to it would report drift on every scheduled run for any configuration that declares a secret, making the check permanently red. docs/adr.md record 4 already excludes secret updates from the definition of an empty plan for this exact reason — this plan carries the same exclusion into drift detection.
Change gains an Opaque bool field, set on both branches of appendSecretChange (blocked and update), because opacity is a property of the resource rather than the state it happens to be in. Plan.HasChanges() is replaced with Plan.HasDrift(), the same create/update predicate narrowed to exclude Opaque entries. An explicit field was chosen over matching the actions.secret. / agents.secret. resource-name prefix, so a display-string change cannot silently break the exit code.
cli.Run keeps returning a plain error; a signature change to carry an exit code directly would add code-1 noise to roughly fifteen return statements for the sake of one. Instead, internal/cli gains a typed DriftError carrying exit code 2, and cmd/gh-repospec/main.go maps it through a small exitCode(err error, stderr io.Writer) int function. A *DriftError returns 2 without printing an error: line — drift is a successful detection, not a failure — any other error prints error: <err> and returns 1, and nil returns 0. The drift notice itself goes to standard error, so --format json on standard output stays a valid document at every exit code, including when a workflow redirects it to a file.
plan --ci fails (exit 1) on a blocked plan even when drift is also present, because a blocked plan is a failure that outranks detection. plan without --ci is unaffected: it keeps exiting 0 in every case, including when blocked, exactly as today.
planner.Options gains ForceSecrets bool. applySecrets's agents bool parameter becomes a named secretTarget type, and Options is threaded through applyActions and applyAgentVariables down to applySecrets rather than adding a second boolean parameter. The createOnly skip becomes conditional on !options.ForceSecrets. The flag does not relax the source-environment-variable requirement — a secret without it is still blocked at plan time, and apply still refuses a blocked plan. --force-secrets is registered for apply only and has no effect on plan output.
| Situation | plan |
plan --ci |
|---|---|---|
| No changes | 0 | 0 |
| Only secret updates (opaque) | 0 | 0 |
| Only manual entries (seed, commands, agent files) | 0 | 0 |
| Create/update present | 0 | 2 |
| Blocked present | 0 | 1 |
| Blocked and drift present | 0 | 1 |
- Add the drift predicate, the exit-code type,
plan --ci, and themainmapping (Issue #28). - Add
apply --force-secretsend to end (Issue #29). - Update the example workflows, README, and the parity table once both land (Issue #30).
Test the exit-code outcome table above at the cli.Run/planOutcome boundary, with a subprocess re-exec test proving main wires the mapping into os.Exit for an offline case. Test that a plan whose only create/update entries are secrets reports no drift under --ci, and that apply --ci and plan --force-secrets are both rejected as unregistered flags. Test --force-secrets against a new fake implementing the secret service interface, covering the baseline createOnly skip, rewriting an existing secret, agent secrets, and that a missing source environment variable still blocks. Run the full test suite, vet, lint, the Linux race build, and git diff --check when implementation is complete.
This plan does not change docs/design.md or docs/design_ja.md — the drift-detection and exit-code sections there already describe the shipped behavior. Documentation and example-workflow updates are scoped to Issue #30 and land only after both #28 and #29 ship, so no document claims behavior before it exists. The decisions recorded here are also written to docs/adr.md and docs/adr_ja.md in the repository, as record 6.