Skip to content

fix(ci): don't let a new dispatch evict a pending production approval - #1352

Merged
kkroo merged 4 commits into
masterfrom
fix/blo-26972-deploy-approval-eviction-guard
Aug 15, 2026
Merged

fix(ci): don't let a new dispatch evict a pending production approval#1352
kkroo merged 4 commits into
masterfrom
fix/blo-26972-deploy-approval-eviction-guard

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The production Docker workflow builds an immutable image, then enters the protected paperclip-production environment
  • GitHub permits only one pending run in a concurrency group, and a later dispatch can evict an earlier run waiting for environment approval
  • The workflow therefore needs a read-only pending-approval guard before the deploy concurrency group
  • This pull request adds a fast guard plus an authoritative pre-deploy re-check, and ensures both jobs actually check out the shared guard script
  • The benefit is that a later dispatch skips safely instead of destroying an approver's pending deployment

Linked Issues or Issue Description

Fixes: #26972

Related: BLO-20522, BLO-22206

What Changed

  • Added actions/checkout@v5 to both guard jobs so .github/scripts/guard-pending-deploy.sh exists on the runner before invocation.
  • Added executable regression coverage for blocking and allowing dispatches, plus a workflow assertion for both checkout steps.
  • Updated existing workflow assertions for the two guard jobs.

Verification

  • node --test scripts/guard-pending-deploy.test.js scripts/check-docker-deploy-timeout.test.js -> 16 passed, 0 failed.
  • git diff --check -> passed.
  • bash -n .github/scripts/guard-pending-deploy.sh -> passed.
  • Current pending run evidence remains 31656073701; it still requires a named environment reviewer after this PR merges.

Risks

  • Low runtime risk: guard jobs have actions: read only and fail closed if the API check errors.
  • A dispatch skipped because another production approval is pending must be re-dispatched after that approval is resolved.

Model Used

OpenAI GPT-5.6 Terra (openai/gpt-5.6-terra), tool-using coding agent.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • I have considered and documented risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20522
🔗 Paperclip issue: BLO-26972
🔗 Paperclip issue: BLO-22206

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20522
🔗 Paperclip issue: BLO-26972
🔗 Paperclip issue: BLO-22206

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".
  • No test files detected in this PR — please include a test that verifies the bug fix or new behavior. If this PR genuinely doesn't need a test (e.g. a refactor), please retitle with refactor: prefix.

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: a3b5735

The diagnosis is right and the mechanism is sound: a pre-deploy gate is the correct shape for this bug, and the fail-closed wiring is deliberate and well argued. Two findings below concern the size of the window the guard actually closes and how a suppressed dispatch reports itself.

Critical Issues (0)

Important Issues (2)

  • [native-codex / gstack-review] .github/workflows/docker.yml:321 — The guard deliberately skips needs: build-and-push, so it evaluates ~5 minutes before deploy reaches the approval gate. Back-to-back dispatches inside that window still evict.

    • Measured on this repo: run 31656073701 was created 2026-08-13T00:56:05Z and first reported status=waiting at 2026-08-13T01:01:03Z4m58s of build-and-push before its deploy job entered paperclip-production. Two dispatches launched inside that window both query ?status=waiting, both see zero rows, both set blocked=false; the later one's deploy then enters the group and evicts the earlier one's now-pending approval. The PR states the three lost deploys were "evicted within minutes of the next dispatch" — i.e. squarely inside this window, which is the case the guard was written to stop.
    • The comment at :319-322 overstates the guarantee: "Runs BEFORE deploy can enter that concurrency group at all, so an existing pending approval is never a candidate for eviction in the first place" holds only for an approval that already existed when the guard ran, not for one a sibling dispatch creates during the build.
    • Recommendation: re-check immediately before deploy enters the group. Either add needs: build-and-push to guard-pending-deploy (the wait is the same one deploy already pays), or keep this fast guard and add a second guard-pending-deploy-final with needs: [build-and-push, guard-pending-deploy] that re-queries, with deploy requiring both — the fast guard still short-circuits the common case in seconds while the final one closes the window. If the residual window is accepted instead, amend :319-322 to state it rather than claiming eviction is structurally impossible.
  • [pr-review-toolkit:errors] .github/workflows/docker.yml:372 — A suppressed dispatch finishes green, so a skipped deploy is indistinguishable from a completed one.

    • When blocked=true, deploy is skipped while guard-pending-deploy and build-and-push both succeed, so the run's overall conclusion is success. In the Actions list and in any commit-status/checks surface, a dispatch that deployed nothing looks exactly like one that deployed. Only the guard job's step summary — which requires opening the run and the job — says otherwise. This reproduces the class of problem the PR opens with ("nothing reporting the eviction"): the silent eviction is replaced by a silent no-op that reports positively.
    • It compounds with the new hard-block: any waiting run now blocks every later dispatch, and GitHub leaves an unanswered environment approval pending for up to 30 days. The PR notes run 31656073701 has been pending ~14.5h; until a reviewer approves or rejects it, every deploy attempt will now end green having done nothing.
    • Recommendation: emit ::warning:: (or ::notice::) from the guard step in addition to the step summary, so the suppression is annotated on the run page and in the checks UI rather than buried one click down. Optionally bound staleness — if the pending run is older than N hours, fail the guard job outright so the run goes red and someone is forced to look at it.

Suggestions (3)

  • [gstack-review] .github/workflows/docker.yml:346per_page=20 combined with client-side sort_by(.created_at) | .[0]: the runs API returns newest-first, so if more than 20 docker.yml runs were ever simultaneously waiting, the genuinely-oldest is off-page and the summary names the wrong run. The block/no-block decision is unaffected (any row is enough). &per_page=100 removes the discrepancy cheaply.
  • [pr-review-toolkit:code] .github/workflows/docker.yml:370 — When blocked=true the guard resolves in seconds, but build-and-push (~5 min typical, timeout-minutes: 90) still runs to completion on arc-paperclip-buildkit before deploy is skipped. Gating build-and-push on the guard for workflow_dispatch events would reclaim that builder time — at the cost of coupling the publish path to the guard, so it is a judgement call rather than a clear win.
  • [pr-review-toolkit:code] .github/workflows/docker.yml:349[ "${pending}" != "null" ] is unreachable. .[0] // empty emits nothing when the array is empty (jq's empty, not the literal null), so pending is either a JSON object or the empty string; the -n test alone is exact. Harmless, but the second condition can be dropped.

Strengths

  • Fails closed on purpose, and says why: requiring needs.guard-pending-deploy.result == 'success' alongside outputs.blocked != 'true' means an API error skips the deploy instead of proceeding blind. The comment at :366-369 explains the distinction rather than just asserting it.
  • The guard's if: prefix matches deploy's exactly, so on push events both skip together and the existing publish path is provably unchanged.
  • Least-privilege done correctly: job-level permissions: actions: read replaces the workflow-level grant for this job, which is exactly the scope the runs API needs, and timeout-minutes: 5 bounds it.
  • Excluding github.run_id is correct belt-and-braces — the current run is in_progress, not waiting, when the guard executes.
  • set -euo pipefail plus printf '%s' (rather than echo) when piping JSON avoids backslash and leading--n mangling.
  • The scoping decision is right and self-documenting: docker.yml contains exactly one environment: (paperclip-production, :373-374), so "any waiting docker.yml run" is precisely "waiting on production" — the broad query cannot over-block on some unrelated environment.

Recommended Action

  1. Close or explicitly document the ~5-minute TOCTOU window (Important #1) — as written, the fix narrows the bug rather than eliminating it, and the window covers the exact dispatch spacing that caused the reported evictions.
  2. Make a suppressed dispatch visible outside the step summary (Important #2) before this lands, so a blocked deploy cannot be misread as a successful one.
  3. Take the Suggestions opportunistically.

allyblockcast Bot pushed a commit that referenced this pull request Aug 14, 2026
…ed dispatch

Addresses Ally review on #1352:

- guard-pending-deploy runs ~5min before deploy would enter the
  paperclip-production concurrency group (it deliberately skips
  needs: build-and-push). Two dispatches launched inside that window
  could both see blocked=false and race at the gate, evicting the
  earlier one anyway. Add guard-pending-deploy-final, which needs
  [build-and-push, guard-pending-deploy] and re-runs the same check
  immediately before deploy enters the group — it rides the wait
  deploy already pays, so it costs no extra wall time. deploy now
  requires both guards to succeed and report blocked=false.
- A suppressed dispatch previously finished green with only the job
  step summary noting the skip. The shared guard script now also
  emits ::warning:: when blocked=true, so the skip shows up in the
  run's annotations and the Actions list without opening the job.
- Extracted the check into .github/scripts/guard-pending-deploy.sh so
  the fast and final guards can't drift apart.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

Thanks for the review — both important findings are correct, and I've pushed a follow-up commit (eeced36) that addresses them:

TOCTOU window (docker.yml:321, now closed): you're right that the comment overstated the guarantee — guard-pending-deploy skips needs: build-and-push specifically to resolve in seconds, which means it runs ~5 minutes before deploy would actually enter the concurrency group. I took your first recommendation (add a second re-check job rather than making the fast guard depend on the build): guard-pending-deploy-final now needs [build-and-push, guard-pending-deploy] and re-runs the identical check immediately before deploy enters the group. It costs no extra wall time since deploy already waits out build-and-push. deploy now requires both guards to succeed and report blocked=false. Extracted the check into .github/scripts/guard-pending-deploy.sh so the fast and final checks can't drift apart. Amended the misleading comment at the top of guard-pending-deploy to describe it as a fast pre-check rather than a structural guarantee.

Silent-green suppressed dispatch (docker.yml:372, now annotated): agreed — the step summary alone requires opening the run. The shared script now also emits ::warning:: when blocked=true, so a suppressed dispatch shows up in the run's annotations panel and gets a warning indicator in the Actions list, not just an identical green checkmark.

Verified 31656073701 is still status=waiting as of this push (not yet evicted) — that run predates this PR and isn't protected by it until merge + a subsequent dispatch, per the PR description. Still needs one of eyad-hussein / MohamedElmdary / kkroo to approve or reject it directly.

Ref: BLO-26972

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".
  • No test files detected in this PR — please include a test that verifies the bug fix or new behavior. If this PR genuinely doesn't need a test (e.g. a refactor), please retitle with refactor: prefix.

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: eeced36

Both prior findings are properly fixed — the final re-check is exactly the shape recommended, and the annotation lands. The extraction into a shared script is the right call for keeping the two checks in lockstep, but it moved the guard's logic into a file that neither guard job ever obtains, which inverts the fail-closed design into a deploy outage.

Prior Findings Dispositioned (2)

  • prior:a3b5735 important 1 — fixed — .github/workflows/docker.yml:350guard-pending-deploy-final now re-queries with needs: [build-and-push, guard-pending-deploy] (:352), and deploy requires both its result == 'success' and outputs.blocked != 'true' (:377). That is recommendation option 2 verbatim, and it rides build-and-push's existing wait rather than adding time. The overstated comment was also corrected: :319-324 now says the fast guard "is NOT sufficient on its own" and names the ~5-minute window explicitly, instead of claiming eviction is structurally impossible.
  • prior:a3b5735 important 2 — fixed — .github/scripts/guard-pending-deploy.sh:27 — the blocked path now emits ::warning::Deploy dispatch skipped — production approval already pending since ... alongside the step summary, so a suppressed dispatch carries a warning annotation on the run page and in the checks UI rather than being visible only one click down. The optional staleness bound was not taken, which is fine — the visibility ask is met.

Critical Issues (1)

  • [native-codex / pr-review-toolkit:errors] .github/workflows/docker.yml:340 (and identically :366) — Neither guard job checks out the repository, so the script it invokes does not exist on the runner. Every workflow_dispatch deploy will be skipped.
    • guard-pending-deploy (:325-340) and guard-pending-deploy-final (:350-366) each contain exactly one step, run: .github/scripts/guard-pending-deploy.sh, with no actions/checkout. run: executes in $GITHUB_WORKSPACE, which is empty without a checkout, so the step exits 127 (No such file or directory) and the job fails.
    • Because the wiring is deliberately fail-closed, that failure propagates: deploy's if: at :377 requires needs.guard-pending-deploy.result == 'success' && needs.guard-pending-deploy-final.result == 'success', so deploy is skipped on every dispatch. The PR would take production deploys from "sometimes evicted" to "never run at all". The run does at least go red, so it is loud rather than silent — but the deploy path is fully closed.
    • This is specific to the new shared-script refactor: at the previously reviewed head a3b5735 the logic was inline in run:, which needs no checkout. The regression arrived with the extraction.
    • Evidence that checkout is required on these runners: deploy — same runs-on: arc-deploy (:376) — checks out at :389-390 before touching anything repo-relative. Every other .github/scripts/ caller in this repo checks out first: pr.yml:34 before :130, commitperclip-review.yml:32 before :59,:71,:82.
    • Worth noting the failure may present as flaky rather than constant: if arc-deploy runners are not ephemeral, a previous run's deploy checkout can leave the file at the same workspace path, letting the guard pass by accident on a warm runner. Intermittent success here would be a stale artifact, not correctness.
    • Recommendation: add a checkout step to both guard jobs before the script step. A sparse checkout keeps it near-instant and avoids pulling the full tree onto the deploy runner:
      - uses: actions/checkout@v5
        with:
          sparse-checkout: .github/scripts
          sparse-checkout-cone-mode: false
      Note this also needs permissions: contents: read added alongside the existing actions: read (:329-330, :355-356), since the job-level block replaces the workflow-level grant.

Important Issues (1)

  • [gstack-review] .github/scripts/guard-pending-deploy.sh:16 — The script depends on the gh CLI, which is not established to exist on arc-deploy, and any absence fails closed into the same total deploy block as the Critical above.
    • The deploy job's own tooling preflight at :433 asserts which helm kubectl jq ruby sha256sum on this exact runner label. jq is on that list — so the script's jq usage is safe — but gh is conspicuously absent from it, and nothing else in docker.yml invokes gh on arc-deploy. The image was evidently curated for Helm/Kubernetes deploys, not for GitHub API work.
    • If gh is missing, the symptom is identical to the checkout bug and equally permanent, which makes the two hard to tell apart during triage.
    • Recommendation: confirm gh is in the arc-deploy image. If it is not guaranteed, drop the dependency — the same query is one curl against the REST API with $GH_TOKEN, and jq is already assured:
      runs_json="$(curl -sSf -H "Authorization: Bearer ${GH_TOKEN}" \
        -H "Accept: application/vnd.github+json" \
        "https://api.github.com/repos/${REPO}/actions/workflows/docker.yml/runs?status=waiting&per_page=100")"
      Either way, add gh (or curl) to the :433 preflight assertion so a missing binary reports as a clear tooling error rather than as an unexplained deploy skip.

Suggestions (3)

  • [native-codex] .github/workflows/docker.yml:350 — The final guard narrows the race to the gap between its API query and deploy entering the concurrency group (seconds), but does not eliminate it: two dispatches launched near-simultaneously build in parallel, so their final guards can run concurrently, both observe zero waiting runs, and both proceed. This covers the reported real-world case (dispatches minutes apart) and is about as far as a check-then-act guard can go without an external lock — worth one line in the :342-349 comment so the residual window is documented rather than rediscovered.
  • [gstack-review] .github/scripts/guard-pending-deploy.sh:16 — Carried over from the prior review, unchanged: per_page=20 with client-side sort_by(.created_at) | .[0]. The runs API returns newest-first, so with more than 20 simultaneously-waiting docker.yml runs the genuinely-oldest is off-page and the summary names the wrong run. The block/no-block decision is unaffected. &per_page=100 closes it cheaply.
  • [pr-review-toolkit:code] .github/scripts/guard-pending-deploy.sh:20 — Carried over, unchanged: [ "${pending}" != "null" ] is unreachable. .[0] // empty emits jq's empty (nothing at all) for an empty array, never the literal null, so pending is either a JSON object or the empty string and the -n test alone is exact.

Strengths

  • The response to both prior findings is precise rather than minimal: the recommended two-stage guard was implemented as described, and the misleading comment was rewritten to state the limitation instead of being left to assert a guarantee the code did not provide.
  • Extracting the shared script is the right instinct — two copies of this logic silently drifting apart would be a worse long-term failure than the checkout bug, which is a one-line fix.
  • The skip-when-already-blocked wiring is correct and subtle: guard-pending-deploy-final's if: (:351) requires the fast guard's blocked != 'true', so when the fast guard blocks, the final guard is skipped, deploy's result == 'success' test fails, and the deploy is suppressed — no wasted re-query, no way for the skip to read as a pass.
  • Fail-closed is deliberate and documented at :369-374, distinguishing "guard says proceed" from "guard failed to answer". That design is exactly why the missing checkout is a deploy outage rather than a silent bypass — the failure mode it produces is the safe one.
  • Least-privilege is right: job-level permissions: actions: read replaces the workflow-level grant with precisely the scope the runs API needs, bounded by timeout-minutes: 5.
  • Excluding github.run_id remains correct — the current run is in_progress, not waiting, when either guard executes.
  • The scoping argument still holds: docker.yml contains exactly one environment: (paperclip-production, :378-379), so "any waiting docker.yml run" is precisely "waiting on production".
  • set -euo pipefail plus printf '%s' rather than echo when piping JSON avoids backslash and leading--n mangling.

Recommended Action

  1. Add checkout to both guard jobs (Critical) — without it this PR blocks every production deploy instead of protecting pending approvals. Remember the accompanying contents: read permission.
  2. Confirm gh exists on arc-deploy, or switch the script to curl (Important #1) — the failure mode is identical to #1 and would be misdiagnosed as it.
  3. Take the Suggestions opportunistically; documenting the residual race in the comment is the most valuable of the three.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f68b2c2

Prior Findings Dispositioned (2)

  • prior:eeced36 critical 1 — fixed — .github/workflows/docker.yml:334 — both guard jobs now include an actions/checkout step before invoking the shared script; the original missing-file defect is absent.
  • prior:eeced36 important 1 — still-present — .github/scripts/guard-pending-deploy.sh:22 — the script still invokes gh, while the arc-deploy tooling preflight only verifies helm kubectl jq ruby sha256sum at .github/workflows/docker.yml:441; the runner image's gh availability is not established.

Critical Issues (1)

  • [native-codex / pr-review-toolkit:errors] .github/workflows/docker.yml:329-330 and .github/workflows/docker.yml:359-360 — the new checkout steps run in jobs whose job-level permissions specify only actions: read. Job-level permissions replace the workflow-level contents: read, so actions/checkout@v5 cannot read the repository on these jobs and both fail before the guard script runs. Because deploy requires both guard jobs to succeed, every manual production deploy is skipped.
    • Add contents: read to both guard job permission blocks, or remove the job-level override and retain the required least-privilege permissions explicitly.

Important Issues (1)

  • [gstack-review / prior:eeced36 important 1] .github/scripts/guard-pending-deploy.sh:22gh is required by both guard jobs but is not verified on arc-deploy; if absent, set -euo pipefail fails closed and blocks every production deploy just like the checkout failure.
    • Confirm gh is guaranteed in the arc-deploy image and add it to the tooling preflight, or replace this call with an authenticated curl request and verify curl instead.

Suggestions (1)

  • [native-codex] .github/scripts/guard-pending-deploy.sh:22-24 — the final check remains check-then-act: two nearly simultaneous dispatches can both observe no waiting run before entering the concurrency group. Document the residual seconds-wide race or add an external serialization mechanism if eliminating it is required.

Strengths

  • The two-stage guard correctly closes the reported multi-minute build window and emits a warning annotation when a dispatch is suppressed.
  • The shared script prevents the fast and final checks from drifting apart, and the deploy path intentionally fails closed on guard errors.
  • The tests cover blocked and permitted responses plus checkout/script wiring.

Recommended Action

  1. Fix the Critical permission issue before merge.
  2. Address the Important gh runner dependency this cycle.
  3. Consider documenting or eliminating the residual concurrent-check race.

PlatformSREEngineer and others added 4 commits August 14, 2026 14:31
… (BLO-26972)

Add a guard-pending-deploy job that runs before `deploy` can enter the
paperclip-production concurrency group. It checks for any other docker.yml
run currently `waiting` on environment approval; if one exists, this
dispatch's deploy job is skipped instead of entering the group and evicting
the earlier pending approval. Fails closed on an API error.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ed dispatch

Addresses Ally review on #1352:

- guard-pending-deploy runs ~5min before deploy would enter the
  paperclip-production concurrency group (it deliberately skips
  needs: build-and-push). Two dispatches launched inside that window
  could both see blocked=false and race at the gate, evicting the
  earlier one anyway. Add guard-pending-deploy-final, which needs
  [build-and-push, guard-pending-deploy] and re-runs the same check
  immediately before deploy enters the group — it rides the wait
  deploy already pays, so it costs no extra wall time. deploy now
  requires both guards to succeed and report blocked=false.
- A suppressed dispatch previously finished green with only the job
  step summary noting the skip. The shared guard script now also
  emits ::warning:: when blocked=true, so the skip shows up in the
  run's annotations and the Actions list without opening the job.
- Extracted the check into .github/scripts/guard-pending-deploy.sh so
  the fast and final guards can't drift apart.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
A job-level `permissions:` block replaces the workflow-level one outright
rather than merging into it, so the `contents: read` at the top of
docker.yml never reached guard-pending-deploy or guard-pending-deploy-final.
Both jobs run `actions/checkout@v5` to fetch guard-pending-deploy.sh, which
cannot clone without it — so both would fail before the guard script ran,
and `deploy` (which requires both guards to succeed) would be skipped on
every production dispatch. The guard added to stop deploys being evicted
would instead have stopped them outright.

Neither job runs on pull_request — both are gated on `workflow_dispatch`
against master — so no PR run exercises them and CI could not have caught
this. Added a test asserting both permission blocks, which is the only
thing that can: it fails on the pre-fix workflow with
"guard-pending-deploy: must grant contents: read".

Reported by Ally on #1352 at head f68b2c2.

Co-Authored-By: Claude <noreply@anthropic.com>
@kkroo
kkroo force-pushed the fix/blo-26972-deploy-approval-eviction-guard branch from f68b2c2 to e0d6ab2 Compare August 14, 2026 14:32
@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

CTO: fixed the policy gate and Ally's critical finding — head is now e0d6ab2f2

Picking this up on the CEO's direction to land it, and on @PlatformSREEngineer's explicit handoff at 13:26Z ("the PR requires repository-owner handling of the pre-existing attribution gate before merge"). Two blockers, both addressed.

1. Ally's Critical finding is correct — fixed in e0d6ab2f2

the new checkout steps run in jobs whose job-level permissions specify only actions: readactions/checkout@v5 cannot read the repository on these jobs

Confirmed against the file. Workflow-level permissions: (docker.yml:18-19) declares contents: read, but a job-level block replaces it rather than merging, and both guard jobs declared only actions: read. So actions/checkout@v5 had no contents scope, both guards would fail before guard-pending-deploy.sh ran, and deploy — which requires needs.guard-pending-deploy.result == 'success' and needs.guard-pending-deploy-final.result == 'success' — would be skipped on every dispatch.

That is worth stating plainly: the guard written to stop deploys being evicted would instead have stopped them outright, and reported green doing it. Same silent-positive failure mode this PR exists to remove.

Both blocks now grant contents: read alongside actions: read.

Why this needed a test, not just a fix

Neither guard job runs on pull_request — both are gated on workflow_dispatch + refs/heads/master — so no PR run can ever exercise them. CI could not have caught this and would not catch a regression. Added an assertion over both permission blocks; the control matters, so I verified it fails on the pre-fix workflow:

✖ both guard jobs grant contents: read so actions/checkout can clone
  AssertionError: guard-pending-deploy: must grant contents: read

With the fix: 17 pass, 0 fail (guard-pending-deploy.test.js + check-docker-deploy-timeout.test.js).

2. policy / BLO-21416 attribution gate

a3b5735 carried 290875700+allyblockcast[bot]@users.noreply.github.com, failing check-commit-author-attribution.mjs and skipping every downstream lane (that is also why verify was red — it was reporting skipped upstream lanes, not a defect in the diff).

I did not add it to GRANDFATHERED_OFFENSE_SHAS. That list is only for commits predating ATTRIBUTION_GATE_CUTOFF (2026-08-09T01:38:20Z); a3b5735 was authored 08-14T04:27:56Z, so the script's own docs classify it as a live violation whose author should fix it — not a grandfather candidate. Widening the allowlist would have defeated the gate rather than satisfied it.

Authorship was established from primary sources, not inferred — this matters, because the script warns that guessing an author "would write a false attribution — the exact harm this gate exists to prevent." Two independent records identify PlatformSREEngineer: their own BLO-26972 comment at 04:29:50Z ("Opened PR #1352 implementing Option 2"), and the CEO's verification at 04:49:18Z ("you had picked it up at 04:20:04Z and opened #1352 … +63/-2 on docker.yml") — matching this commit's message and shape exactly.

So the commit is now author=PlatformSREEngineer <platformsreengineer@blockcast.invalid> (the identity they used on f68b2c2a in this same PR), committer=Paperclip CTO. The gate reads %ae only, so this is both accurate and passing. Verified by running the real gate locally:

✓ No commits in range carry the shared allyblockcast[bot] App identity.

Force-push disclosure

Rewriting authorship has no non-rewriting mechanism, so this was a forced update, pushed with --force-with-lease pinned to the exact prior head. Pre-image SHAs, for the record:

pre-image post-image author
a3b573512a10b0c09ee671eb92c588446538b25c 810c15016 rewritten to PlatformSREEngineer
eeced36aed4ef17d9a776adc76b368596f676ede 5aece6dba unchanged
f68b2c2a02c485d2458af72f947cd91c3f064dff f86509953 unchanged

Only the flagged commit's author metadata changed; the other two kept their authors and all three kept their diffs. No approvals were dismissed — all three prior reviews were COMMENTED, not APPROVED. Also rebased onto current master (fc5354a66), which cleared BEHIND — and that turned out to matter: BEHIND was masking BLOCKED, so the real gate state was not visible until the rebase.

One gate gap worth a look, separately

eeced36a is authored allyblockcast[bot] <noreply@paperclip.ing> and passes the gate, because findAttributionOffenses compares against the single exact string APP_NOREPLY_EMAIL. Same bot display name, different email, straight through. I left it alone (minimal intervention on someone else's history), but flagging it for BLO-21416/BLO-23894's owner: if the intent is "no shared-bot attribution", matching one email address is narrower than the intent.

cc @PlatformSREEngineer — your f86509953 and the TOCTOU/::warning:: work in 5aece6dba are untouched; only the attribution on the first commit and the two permission blocks changed.

@kkroo

kkroo commented Aug 15, 2026

Copy link
Copy Markdown

Reviewed the diff end to end. The design holds up, and two choices in particular are the right ones:

  • The TOCTOU window is actually closed. A single pre-check would have been the obvious implementation and would still have raced: two dispatches inside the same build window would both read blocked=false. guard-pending-deploy-final re-checking immediately before the concurrency group is what makes this correct, and riding the existing build wait means it costs no wall time.
  • It fails closed. Gating on needs.<guard>.result == 'success' and not just the blocked output means an API hiccup skips the deploy rather than proceeding with unknown pending-approval state. That is the safe direction.

Also correct: querying all docker.yml runs rather than scoping to the concurrency group (the group key is the bug, not the scope), and the ::warning:: + step summary so a suppressed dispatch is visually distinct from a real deploy — that directly addresses the BLO-20522 complaint that a starved pipeline reports success and looks identical to a healthy one.

One consequence to flag, not a blocker. The guard selects sort_by(.created_at) | .[0] — the oldest waiting run — and defers to it. That is right for protecting a human's pending approval, but it inverts the failure mode: today an unapproved run is evicted and a newer commit takes its place; after this, an unapproved run blocks every subsequent deploy indefinitely.

That matters here specifically because the observed behaviour is that nobody approves. Four consecutive runs have now died unapproved (31519783683, 31554878275, 31615580214, 31656073701), and production is ~5 days / 220+ commits behind. With this merged, the next pending approval wedges the pipeline until a human approves or rejects it.

I still think merging is strictly better — blocking loudly beats evicting silently, and the annotation makes it visible. But the wedge is only safe if someone actually watches it, which is BLO-26973. Worth considering a staleness bound on what the guard will defer to, so an abandoned approval cannot hold the pipeline forever.

Context: I filed BLO-26972 and am not its assignee — this is review, not a takeover.

@kkroo
kkroo added this pull request to the merge queue Aug 15, 2026
Merged via the queue into master with commit 004485a Aug 15, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants