Skip to content

Find the stories git can't name, and reconcile the Deploy Ready backlog - #3704

Open
yodem wants to merge 6 commits into
masterfrom
fix/sc-46815/deploy-ready-reconciliation
Open

Find the stories git can't name, and reconcile the Deploy Ready backlog#3704
yodem wants to merge 6 commits into
masterfrom
fix/sc-46815/deploy-ready-reconciliation

Conversation

@yodem

@yodem yodem commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Two prod runs of the release-announcement pipeline left stories stuck in Deploy Ready and under-reported the Slack notes. Root-caused to two independent defects, neither of which is a bug in any single step.

Measured against the 20 stories standing in Deploy Ready after 7.1.3:

count
Shipped — linked PR is an ancestor of the prod tag 11 now transitioned
Correctly pending — merged, genuinely not in prod 2 left alone
No resolvable PR — needs a human 7 reported, not guessed at

1. Discovery ran in one direction only

shipped_stories.py found a story only if sc-NNNNN appeared in a commit subject or PR head branch. Shortcut's own PR↔story links were never consulted.

This PR's own story is the demonstration: it shipped on branch feature/prod-rollout-slack-release-notes, so the release-announcement pipeline could not announce itself — even though Shortcut held the link the whole time. 1 of 20.

Fixed by adding search/stories?query=pr:<N> as a second, independent discovery direction.

2. Nothing ever revisited work outside the current tag range — the dominant defect

Each run walks prev_tag..cur_tag only. A story missed once, or shipped before this pipeline existed, is never looked at again. sc-46548 shipped in 6.111.0 on Aug 12 with a correctly-named branch and was still Deploy Ready a month later. 10 of 20.

Fixed by build/ci/reconcile_deploy_ready.py: an org-wide sweep, every release, over every non-archived Deploy Ready story.

Everything here is deterministic

No LLM decides what shipped or writes any state. Story resolution is a git walk plus REST calls; "did it reach prod" is git merge-base --is-ancestor; transitions are a for-loop. The agent's role is unchanged — it writes prose and holds Read,Write,Glob,Grep only.

Three guards before a linked PR counts as evidence

Each caught a real false positive in live testing:

  1. merged — an unmerged PR shipped nothing.
  2. repository_id — a story linking PR #224 in another repo otherwise resolves that number against this repo, matches an unrelated older PR, and tests as "in prod".
  3. target_branch_name == master — promotion merges resolve to a real story through pr:<N> just as readily as the genuine feature PR does.

They live in one shared module (shortcut_pr_guards.py). The first pass applied them to the sweep but not to the new fallback and the two drifted immediately — the fallback started adopting story ids from promotion merges.

Reconciliation output is kept out of the announcement

Backfilled stories shipped in earlier releases; if they reach the prose step the announcement claims a dozen old features shipped today. The report is written to $RUNNER_TEMP, not the checkout — the prose agent holds Glob+Read over its working directory, so a distinct filename is a naming convention, not an access boundary.

Same principle in the write-back comment: the sweep derives each story's true release via git tag --list 'prod/*' --contains <oid> rather than naming the current one. Verified across 9 distinct releases from 6.79.0 to 7.1.3.

Reviewer notes

  • Merging arms the backfill. The first prod-rollout-succeeded after merge transitions the whole standing backlog at once and posts a comment on each — not just that release's stories. Say the word if you'd rather gate it behind one manual run.
  • Both scripts are dry-run by default; --apply is required to mutate. --no-comment opts out of the write-back.
  • The sweep is Standard-workflow-only. Deploy Ready doesn't exist in the Content workflow; other-workflow stories are routed to triage with their workflow/state ids, never transitioned.
  • Enumeration uses the token'd search/stories endpoint, which is org-wide — unlike iterations-get-active, which is silently scoped to the caller's own teams and has produced silently-incomplete results here before.
  • Tests aren't collected by pytest.ini. Run by path:
    python3 -m pytest build/ci/tests/ -q -p no:django -c /dev/null174 passed
  • Reconcile dry-run against live Shortcut reproduces 11/2/7 with zero mutations and zero comments posted.

Background and the reusable pattern: concepts/Shortcut Story-to-Deploy Attribution in the wiki.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KJqBksQqzHYs3F54Y4tJRX

yodem and others added 2 commits September 7, 2026 08:11
…y Ready backlog

The prod release-notes pipeline resolved shipped stories in one direction
only -- git to Shortcut -- and only ever within the current prod tag range.
Two consequences, both observed live against the 20 stories standing in
Deploy Ready after the 7.1.3 rollout:

  - A PR whose branch and commit subject carry no story code was invisible,
    even though Shortcut itself held the PR-to-story link. (1 of 20.)
  - Anything that shipped in an earlier range was never revisited and stayed
    in Deploy Ready permanently. (10 of 20 -- the dominant cause.)

shipped_stories.py gains Shortcut's PR link as a third discovery source,
resolved via `search/stories?query=pr:<N>`, feeding both the transition and
the announcement prose.

reconcile_deploy_ready.py (new) sweeps every non-archived Deploy Ready story
org-wide each release and transitions those whose linked PR already reached
prod, whichever release shipped it. Enumeration uses the token'd search
endpoint, which -- unlike iterations-get-active -- is not scoped to the
caller's own teams. Shipping evidence is a PR merge commit being an ancestor
of the prod tag, not a commit-subject grep. It is dry-run by default and
never writes into shipped-stories.json: those stories shipped in earlier
releases, and leaking them into today's announcement would have Slack claim
old features shipped today. Its report goes to $RUNNER_TEMP rather than the
checkout, because the prose agent holds Glob+Read over its working directory
and a distinct filename is a naming convention, not an access boundary.

Three guards decide whether a linked PR counts as evidence -- merged, the
right repository, and targeting master. Each caught a real false positive:
a story linked to a PR in another repo resolves against this one as an
unrelated older PR, and a promotion merge resolves to a story just as
readily as that story's real feature PR does. They live in one shared module
because the first implementation applied them to the sweep but not to the
new fallback, and the two drifted immediately.

Stories with no resolvable PR are reported for human triage rather than
guessed at.

Tests are not collected by pytest.ini and must be run by path with
`-p no:django -c /dev/null`; see the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqBksQqzHYs3F54Y4tJRX
mark_stories_deployed.py and reconcile_deploy_ready.py only ever wrote a
story's workflow state; nothing recorded which release actually carried
it. Both now post a short comment via the shared shortcut_comment.py
immediately after a real transition. mark_stories_deployed.py names its
own release directly (it's reading that release's shipped-stories.json);
reconcile_deploy_ready.py instead derives the TRUE shipping release from
`git tag --list --contains <merge-oid>` and degrades honestly if that
can't be resolved, since naming the current prod tag for a backfilled
story would be the same "old features shipped today" mistake this
pipeline's other separations already guard against. Comments are never
posted in dry-run/no-apply mode or with the new --no-comment flag, and a
comment failure is reported but never fails the run or rolls back the
transition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqBksQqzHYs3F54Y4tJRX
@gitvelocity-reviewer

Copy link
Copy Markdown

📊 Code Quality Score: 27/100

Base Score 68 × ESF 0.4 (Small tier, 77 effective lines, 1 file per pre-computed metrics) = 27.2

Category Score Factors
🔭 Scope 14/20 Seven files are touched: the prod-release-notes workflow YAML, shipped_stories.py, mark_stories_deployed.py, two new shared modules (shortcut_pr_guards.py, shortcut_comment.py), and two test files (test_mark_stories_deployed.py, test_reconcile_deploy_ready.py). reconcile_deploy_ready.py is a new 684-line standalone script. shortcut_pr_guards.py is imported by both shipped_stories.py and reconcile_deploy_ready.py, making it a new cross-cutting dependency within the CI subsystem.
🏗️ Architecture 13/20 shortcut_pr_guards.py extracts the three PR shipping-evidence guards (merged / right repo / target branch master) that both shipped_stories.py and reconcile_deploy_ready.py must apply identically, replacing two would-be parallel copies. shortcut_comment.py extracts the POST /stories/{id}/comments mechanics shared by mark_stories_deployed.py and reconcile_deploy_ready.py, while deliberately leaving comment-text construction in each caller because the two scripts know different things about which release shipped a story. The workflow adds reconcile_deploy_ready.py as a separate step with its output written to $RUNNER_TEMP rather than GITHUB_WORKSPACE, establishing an access boundary that prevents the reconcile report from leaking into the prose-generation step.
⚙️ Implementation 15/20 reconcile_deploy_ready.py implements four-guard PR classification (workflow id, numeric state id, repo id, target branch), paginated Shortcut search via cursor, concurrent gh merge-OID resolution via ThreadPoolExecutor, and git ancestry checks via git merge-base --is-ancestor with a three-valued return (True/False/None for inconclusive). resolve_shipping_release_tag uses ascending --sort=creatordate to find the earliest prod/* tag containing a commit, the opposite of resolve_default_prod_tag's descending sort. shipped_stories.py adds a third discovery source with a two-layer promotion-PR filter: NOISE_PATTERN pre-filters before any network call, and LONG_LIVED_ENV_BRANCHES filters on the PR's head branch. _reconcile_comment_text builds different comment text than _release_comment_text in mark_stories_deployed.py because the two scripts have different knowledge about which release shipped a story.
⚠️ Risk 10/20 reconcile_deploy_ready.py mutates Shortcut workflow state for potentially many stories across multiple teams in a single run; --dry-run is the default and --apply is required to mutate. The workflow step uses continue-on-error: true so a reconcile failure never blocks release notes generation or Slack posting. The $RUNNER_TEMP isolation prevents the reconcile report from being read by the prose-generation step even if the agent holds Glob+Read over its working directory. A wrong ancestry check (is_ancestor_of_prod returning True incorrectly) would transition a story whose PR has not yet reached prod; the three-valued return (None for inconclusive) ensures resolution failures push toward pending rather than shipped.
✅ Quality 13/15 test_reconcile_deploy_ready.py (768 lines) covers all three classification buckets, all four guards individually and in combination, pagination via cursor, ancestry check exit-code interpretation (0/1/other), dry-run default, --apply, --dry-run overriding --apply, --no-comment, comment API failure non-fatality, resolve_shipping_release_tag ascending-sort behavior, and the regression case where the comment must name the true earlier release rather than the current prod tag. test_shipped_stories.py adds 155 lines covering the RC1 fallback: single match adoption, all three guard rejections, no-match quiet return, ambiguous-match warn-and-skip, HTTP and network error resilience, promotion-PR NOISE_PATTERN pre-filter, LONG_LIVED_ENV_BRANCHES pre-filter, token-absent degradation, and lookup-failure non-abort. test_mark_stories_deployed.py adds 8 tests for comment posting, failed-transition no-comment, dry-run no-POST, dry-run preview text, --no-comment suppression, and API failure non-fatality. shortcut_pr_guards.py and shortcut_comment.py have no dedicated test files; their behavior is covered indirectly through the two existing test files.
🔒 Perf / Security 3/5 fetch_merge_oids and fetch_stories_by_pr both use ThreadPoolExecutor with max_workers=8 for concurrent I/O. SHORTCUT_API_TOKEN is required even for --dry-run in reconcile_deploy_ready.py (documented in the die() message). The $RUNNER_TEMP output path for the reconcile report is described in the workflow comment as a deliberate boundary: a same-directory JSON with real story names is identified as the specific failure mode it prevents, not just a naming convention.

Was this score accurate? 👍 Yes · 👎 No

How this was scored →

Scored by GitVelocity · How are scores calculated?

yodem and others added 4 commits September 7, 2026 09:50
…g (sc-47043)

reconcile_deploy_ready.py's triage bucket only reports raw diagnostics
(no_qualifying_pr / non_standard_workflow_or_state), leaving a human to
open every story and work out why individually. This adds an opt-in
headless-Claude explainer step that proposes a labeled hypothesis and a
suggested next action per triage story.

Kept fully out of the deterministic Python: reconcile_deploy_ready.py
gained no API client, just richer in-memory triage context (description,
comments, per-linked-PR guard diagnostics -- all already available on the
same Shortcut search response, no extra calls). A new sibling script,
build/ci/triage_explainer.py, is a stdlib-only JSON transform that
extracts ONLY the triage bucket into its own file -- shipped/pending are
structurally absent, not merely excluded by prompt instruction, since a
triage story's description/comments are contributor-controlled text. The
actual `claude -p` call lives entirely in a new, opt-in workflow step,
provisioned identically to the existing release-notes prose step
(--allowedTools Read,Write,Glob,Grep only, no Bash, no network), and its
output stays in $RUNNER_TEMP so the prose step can't pick it up. It never
mutates Shortcut and never influences the shipped/pending buckets or any
transition.

Opt-in and off by default on every trigger path: a workflow_dispatch run
via its own explain_triage input, the automatic repository_dispatch
trigger via a repo-level Actions variable (vars.ENABLE_TRIAGE_EXPLAINER).
The decision itself (triage_explainer.resolve_enabled) is tested Python,
not inline bash. Degrades cleanly (continue-on-error, same posture as the
existing bookkeeping steps) when disabled, when ANTHROPIC_API_KEY is
absent, or when the call fails.

Note: verified against this repo's actual configured secrets (gh secret
list) that the correctly-spelled ANTHROPIC_API_KEY is the only secret that
exists -- used that instead of a claimed ANTHOPIC_API_KEY misspelling,
which does not exist here and would have silently disabled this feature
permanently in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqBksQqzHYs3F54Y4tJRX
… current-release stories into notes

Three changes to the Deploy Ready reconciliation pipeline:

1. Remove the Shortcut write-back comment feature added previously
   (product decision: transitioning to Done is enough, no per-story
   comment needed). Deletes shortcut_comment.py, the comment-text
   builders, --no-comment flags, and comment_posted/comment_failed/
   would_comment fields from both mark_stories_deployed.py and
   reconcile_deploy_ready.py. resolve_shipping_release_tag() is kept --
   change 3 below needs it.

2. Fix a guard drift in the opposite direction from the one fixed
   previously: reconcile_deploy_ready.py had no guard against a PR whose
   HEAD branch is a long-lived environment branch (a promotion PR merging
   preprod -> master passes merged/repo/target-branch cleanly, since its
   target really is master). shipped_stories.py's RC1 fallback already
   had this check for its own purposes; the two scripts drifted apart on
   whether it existed at all. Unified as a fourth PR-level guard in the
   shared shortcut_pr_guards.py, applied by both callers.

3. A story the reconciliation sweep backfills can, in the ordinary case,
   have shipped in the CURRENT release rather than an earlier one --
   silently excluding it from today's announcement was a different
   flavor of the same "say what actually shipped today" mistake, just by
   omission instead of leakage. reconcile_deploy_ready.py now attaches
   shipping_release_tag (derived via resolve_shipping_release_tag) and,
   only when it equals the current prod tag, a hydrated_story sub-object
   to each shipped report entry. A new, separate, deterministic script
   (merge_release_backfill.py) trusts that upstream decision and folds
   only those pre-filtered entries into shipped-stories.json, deduped by
   id, before the prose step runs -- the reconcile report's shipped/
   pending/triage buckets as a whole still never reach that step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqBksQqzHYs3F54Y4tJRX
Trims module docstrings, function docstrings, and inline comments across
the release-notes pipeline scripts, the workflow YAML, and the README down
to what's needed operationally. No behavior change: only comments,
docstrings, and markdown were touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqBksQqzHYs3F54Y4tJRX
1. Pass --prod-tag (from shipped-stories.json's range.current_tag) to
   reconcile_deploy_ready.py in the workflow -- it was silently falling
   back to the newest prod/* tag on every run, including a workflow_dispatch
   re-run for an older release, which would wrongly gate hydrated_story
   against the wrong tag.
2. reconcile_deploy_ready.py now warns and exits non-zero (under --apply
   only) when it saw stories, transitioned none, and routed some to
   triage -- mirrors mark_stories_deployed.py's existing silent-no-op
   guard.
3. Scope the triage-explainer Claude step's cwd to $RUNNER_TEMP -- its
   Write grant previously covered the checkout, including
   shipped-stories.json, while processing contributor-controlled text.
4. Gate the RC1 PR-link fallback on `not c["is_revert"]` -- a revert's
   own merge-commit PR could otherwise get adopted into
   stories_from_shortcut_pr_link despite never reaching story_ids.
5. Add steps.merge_backfill.outcome == 'failure' to both bookkeeping
   warn/alert step conditions -- a merge failure was invisible.
6. Pin the Claude Code CLI install to @2.1.263 (the version currently
   resolving) instead of floating on latest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqBksQqzHYs3F54Y4tJRX
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.

1 participant