diff --git a/.github/workflows/advance-deploy-env.yml b/.github/workflows/advance-deploy-env.yml index 6c8b95a..4fb77ae 100644 --- a/.github/workflows/advance-deploy-env.yml +++ b/.github/workflows/advance-deploy-env.yml @@ -1,8 +1,15 @@ name: Advance deploy environment # Reusable workflow. Called from each active repo on push to develop/staging/master/main. -# For each PR contained in the push, updates the Deploy environment project field. -# When the push is to master/main, also promotes Status → Done. +# For each PR contained in the push, updates the Deploy environment project field +# and advances Status through the multi-stage validation flow: +# develop → Status = "FR on dev" (functional review on dev environment) +# staging → Status = "FR on staging" (functional review on staging environment) +# master/main → Status = "Prod" (shipped to production) +# +# The "Ready for staging" / "Ready for prod" intermediate states are set manually +# (drag-and-drop on the kanban or via a /fr-pass comment) when the FR reviewer +# declares the validation passed but the deploy hasn't happened yet. on: workflow_call: @@ -94,24 +101,28 @@ jobs: | select(.name=="Deploy environment") | .options[] | select(.name==$e) | .id') STATUS_FIELD=$(echo "$PROJ" | jq -r '.data.organization.projectV2.fields.nodes[] | select(.name=="Status") | .id') - DONE_OPT=$(echo "$PROJ" | jq -r '.data.organization.projectV2.fields.nodes[] - | select(.name=="Status") | .options[] | select(.name=="Done") | .id') + + # Resolve the Status option that matches this push's target environment. + case "$DEPLOY_ENV" in + dev) STATUS_NAME="FR on dev" ;; + staging) STATUS_NAME="FR on staging" ;; + prod) STATUS_NAME="Prod" ;; + esac + STATUS_OPT=$(echo "$PROJ" | jq -r --arg s "$STATUS_NAME" '.data.organization.projectV2.fields.nodes[] + | select(.name=="Status") | .options[] | select(.name==$s) | .id') if [ -z "$DEPLOY_OPT" ] || [ "$DEPLOY_OPT" = "null" ]; then echo "Could not resolve Deploy environment option for '$DEPLOY_ENV' — aborting" exit 1 fi - # Status update is a nice-to-have for prod pushes; degrade gracefully - # if the Status field or its "Done" option can't be resolved, rather - # than failing the whole workflow and masking the successful Deploy - # environment update. - if [ "$DEPLOY_ENV" = "prod" ]; then - if [ -z "$STATUS_FIELD" ] || [ "$STATUS_FIELD" = "null" ] \ - || [ -z "$DONE_OPT" ] || [ "$DONE_OPT" = "null" ]; then - echo "::warning::Could not resolve 'Done' option in Status field of project #$PROJECT_NUMBER — skipping Status updates" - SKIP_STATUS=1 - fi + # Status update degrades gracefully: if the Status field or its target + # option can't be resolved, log a warning and skip just the Status step + # rather than failing the whole workflow (Deploy env update still wins). + if [ -z "$STATUS_FIELD" ] || [ "$STATUS_FIELD" = "null" ] \ + || [ -z "$STATUS_OPT" ] || [ "$STATUS_OPT" = "null" ]; then + echo "::warning::Could not resolve Status option '$STATUS_NAME' in project #$PROJECT_NUMBER — skipping Status updates" + SKIP_STATUS=1 fi for prnum in ${{ steps.prs.outputs.prs }}; do @@ -145,14 +156,14 @@ jobs: }) { projectV2Item { id } } }' -F p="$PROJECT_ID" -F i="$ITEM_ID" -F f="$DEPLOY_FIELD" -F o="$DEPLOY_OPT" > /dev/null - if [ "$DEPLOY_ENV" = "prod" ] && [ "${SKIP_STATUS:-0}" != "1" ]; then - echo "→ PR #$prnum: Status = Done (prod)" + if [ "${SKIP_STATUS:-0}" != "1" ]; then + echo "→ PR #$prnum: Status = $STATUS_NAME" gh api graphql -f query=' mutation($p: ID!, $i: ID!, $f: ID!, $o: String!) { updateProjectV2ItemFieldValue(input: { projectId: $p, itemId: $i, fieldId: $f, value: {singleSelectOptionId: $o} }) { projectV2Item { id } } - }' -F p="$PROJECT_ID" -F i="$ITEM_ID" -F f="$STATUS_FIELD" -F o="$DONE_OPT" > /dev/null + }' -F p="$PROJECT_ID" -F i="$ITEM_ID" -F f="$STATUS_FIELD" -F o="$STATUS_OPT" > /dev/null fi done diff --git a/.github/workflows/fr-gate-caller.yml b/.github/workflows/fr-gate-caller.yml new file mode 100644 index 0000000..65a55e6 --- /dev/null +++ b/.github/workflows/fr-gate-caller.yml @@ -0,0 +1,15 @@ +name: FR gate + +# Per-repo caller. Copy into each active repo's .github/workflows/. +# Should also be configured as a required status check on the +# staging and main/master branches via branch protection. + +on: + pull_request: + branches: [staging, main, master] + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] + +jobs: + gate: + uses: tracebloc/.github/.github/workflows/fr-gate.yml@main + secrets: inherit diff --git a/.github/workflows/fr-gate.yml b/.github/workflows/fr-gate.yml new file mode 100644 index 0000000..7dd9093 --- /dev/null +++ b/.github/workflows/fr-gate.yml @@ -0,0 +1,166 @@ +name: FR gate + +# Reusable workflow. Called from each active repo on pull_request events +# targeting staging, main, or master. Blocks the merge unless every item +# included in the promotion is in the correct "Ready for X" column: +# +# target = staging → all items must be in "Ready for staging" +# target = main/master → all items must be in "Ready for prod" +# +# This enforces the "FR must pass before promotion" rule. It runs as a +# required status check (configured via branch protection) so the merge +# button stays grey until the gate passes. +# +# Override: add the "skip-fr-gate" label to bypass the check (for hotfixes +# or emergency releases). The label is a deliberate, visible action so we +# can audit overrides after the fact. +# +# Item discovery: extracts PR numbers from the commit subjects between +# base and head (squash-merge "(#NNN)" or "Merge pull request #NNN"). If +# the PR has no such refs (e.g. direct hotfix), falls back to checking +# the promotion PR's own Status. + +on: + workflow_call: + inputs: + project-number: + type: number + default: 2 + org: + type: string + default: tracebloc + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Determine required Status from target branch + id: target + env: + BASE: ${{ github.base_ref }} + run: | + case "$BASE" in + staging) echo "required=Ready for staging" >> "$GITHUB_OUTPUT" ;; + main|master) echo "required=Ready for prod" >> "$GITHUB_OUTPUT" ;; + *) echo "required=" >> "$GITHUB_OUTPUT" ;; + esac + + - name: Skip if not promoting to staging/main/master + if: steps.target.outputs.required == '' + run: echo "Target branch '${{ github.base_ref }}' is not gated — nothing to enforce." + + - name: Check for skip-fr-gate label + id: skip + if: steps.target.outputs.required != '' + env: + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + if echo "$LABELS" | grep -q '"skip-fr-gate"'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::warning::FR gate bypassed via 'skip-fr-gate' label." + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Discover items in this promotion + id: items + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + env: + BASE_REF: ${{ github.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # Make the base branch tip available locally for the diff. + git fetch origin "$BASE_REF":"refs/remotes/origin/$BASE_REF" --depth=200 2>/dev/null || true + BASE_SHA=$(git rev-parse "origin/$BASE_REF") + + # Extract PR refs from commit *subjects* only (avoids "Closes #N" issue refs). + PRS=$(git log --format='%s' "$BASE_SHA..$HEAD_SHA" \ + | grep -oE '\(#[0-9]+\)|Merge pull request #[0-9]+' \ + | grep -oE '#[0-9]+' | tr -d '#' | sort -u | tr '\n' ' ' || true) + echo "Items in promotion: $PRS" + echo "prs=$PRS" >> "$GITHUB_OUTPUT" + + - name: Verify each item is in required Status + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.PROJECTS_KANBAN_TOKEN }} + ORG: ${{ inputs.org }} + PROJECT_NUMBER: ${{ inputs.project-number }} + REPO_FULL: ${{ github.repository }} + REQUIRED: ${{ steps.target.outputs.required }} + PRS: ${{ steps.items.outputs.prs }} + PROMOTION_PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + REPO_NAME="${REPO_FULL#*/}" + + # If commit subjects yielded no PR refs, gate the promotion PR itself. + NUMBERS="$PRS" + if [ -z "$(echo "$NUMBERS" | tr -d ' ')" ]; then + echo "No PR refs in commit subjects — falling back to gating the promotion PR (#$PROMOTION_PR) itself." + NUMBERS="$PROMOTION_PR" + fi + + BLOCKED="" + MISSING="" + PASSED="" + for num in $NUMBERS; do + STATUS=$(gh api graphql -f query=' + query($org: String!, $repo: String!, $num: Int!) { + repository(owner: $org, name: $repo) { + pullRequest(number: $num) { + projectItems(first: 10) { + nodes { + project { number } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + }' -F org="$ORG" -F repo="$REPO_NAME" -F num="$num" 2>/dev/null \ + | jq -r --arg n "$PROJECT_NUMBER" '.data.repository.pullRequest.projectItems.nodes[]? + | select(.project.number == ($n | tonumber)) | .fieldValueByName.name // ""' | head -1) || STATUS="" + + if [ -z "$STATUS" ]; then + echo " ⚪ #$num — not on kanban, skipping" + MISSING="$MISSING #$num" + continue + fi + + if [ "$STATUS" = "$REQUIRED" ]; then + echo " ✅ #$num — Status='$REQUIRED'" + PASSED="$PASSED #$num" + else + echo " ❌ #$num — Status='$STATUS', required='$REQUIRED'" + BLOCKED="$BLOCKED #$num($STATUS)" + fi + done + + echo "" + if [ -n "$BLOCKED" ]; then + echo "::error::FR gate FAILED. Items not yet '$REQUIRED':$BLOCKED" + echo "" + echo "How to unblock:" + echo " 1. Run /fr-pass on each PR once functional review passes on the previous env, OR" + echo " 2. Drag each card to '$REQUIRED' on the engineer kanban." + echo "" + echo "Emergency override: add the 'skip-fr-gate' label to this PR (visible in audit)." + exit 1 + fi + + echo "✓ FR gate PASSED. All items are in '$REQUIRED'." + # Use an `if` here, not `[ -n ... ] && echo`. With `set -e`, the bracket + # test returning 1 on empty MISSING (the normal happy path) would short- + # circuit && and become the script's exit code, failing the step. + if [ -n "$MISSING" ]; then + echo " (skipped not-on-kanban:$MISSING)" + fi diff --git a/.github/workflows/fr-pass-comment-caller.yml b/.github/workflows/fr-pass-comment-caller.yml new file mode 100644 index 0000000..ff1f2ed --- /dev/null +++ b/.github/workflows/fr-pass-comment-caller.yml @@ -0,0 +1,15 @@ +name: FR pass comment + +# Template for each active repo. Copy this file into a repo's +# .github/workflows/ directory to enable the /fr-pass comment shortcut +# for advancing kanban items from "FR on dev" or "FR on staging" to the +# next column. + +on: + issue_comment: + types: [created] + +jobs: + advance: + uses: tracebloc/.github/.github/workflows/fr-pass-comment.yml@main + secrets: inherit diff --git a/.github/workflows/fr-pass-comment.yml b/.github/workflows/fr-pass-comment.yml new file mode 100644 index 0000000..88325ad --- /dev/null +++ b/.github/workflows/fr-pass-comment.yml @@ -0,0 +1,146 @@ +name: FR pass comment handler + +# Reusable workflow. Called from each active repo on issue_comment created. +# Listens for "/fr-pass" comments on PRs/issues that are currently in +# "FR on dev" or "FR on staging" and advances them to the next column: +# FR on dev → Ready for staging +# FR on staging → Ready for prod +# +# Only repo collaborators can trigger this (MEMBER / OWNER / COLLABORATOR). +# The workflow reacts on the comment with 👍 on success, 👎 on no-op so the +# author gets immediate visual feedback without a follow-up comment. +# +# Why a comment-based gate instead of a kanban field: it leaves a record on +# the PR/issue thread so reviewers can see who passed FR and when. +# Drag-and-drop on the kanban also works — this is just a keyboard shortcut. + +on: + workflow_call: + inputs: + project-number: + type: number + default: 2 + org: + type: string + default: tracebloc + +jobs: + advance: + # Skip unless the comment body actually contains /fr-pass. + # Restrict to repo collaborators to prevent random commenters from advancing items. + if: | + contains(github.event.comment.body, '/fr-pass') && + (github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'COLLABORATOR') + runs-on: ubuntu-latest + steps: + - name: Resolve current Status and advance one column + id: advance + env: + GH_TOKEN: ${{ secrets.PROJECTS_KANBAN_TOKEN }} + ORG: ${{ inputs.org }} + PROJECT_NUMBER: ${{ inputs.project-number }} + REPO_FULL: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + IS_PR: ${{ github.event.issue.pull_request != null }} + run: | + set -euo pipefail + REPO_NAME="${REPO_FULL#*/}" + + # One query, two roots: project metadata + item Status. + # Uses $itemNum for the issue/PR number to keep it distinct from $num (project). + if [ "$IS_PR" = "true" ]; then + CONTENT_QUERY='pullRequest(number: $itemNum)' + else + CONTENT_QUERY='issue(number: $itemNum)' + fi + + PROJ=$(gh api graphql -f query=" + query(\$org: String!, \$num: Int!, \$repo: String!, \$itemNum: Int!) { + organization(login: \$org) { + projectV2(number: \$num) { + id + field(name: \"Status\") { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } + repository(owner: \$org, name: \$repo) { + $CONTENT_QUERY { + projectItems(first: 10) { + nodes { + id + project { number } + fieldValueByName(name: \"Status\") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + }" -F org="$ORG" -F num="$PROJECT_NUMBER" -F repo="$REPO_NAME" -F itemNum="$NUMBER" 2>/dev/null) || PROJ='{}' + + PROJECT_ID=$(echo "$PROJ" | jq -r '.data.organization.projectV2.id // empty') + STATUS_FIELD=$(echo "$PROJ" | jq -r '.data.organization.projectV2.field.id // empty') + ITEM_ID=$(echo "$PROJ" | jq -r --arg n "$PROJECT_NUMBER" ' + ([.. | objects | select(has("projectItems"))] | first).projectItems.nodes[]? + | select(.project.number == ($n | tonumber)) | .id' | head -1) + CURRENT=$(echo "$PROJ" | jq -r --arg n "$PROJECT_NUMBER" ' + ([.. | objects | select(has("projectItems"))] | first).projectItems.nodes[]? + | select(.project.number == ($n | tonumber)) | .fieldValueByName.name // ""' | head -1) + + if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then + echo "Item #$NUMBER not on project #$PROJECT_NUMBER — nothing to do." + echo "result=not-on-project" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Decide the next status from the current one. + case "$CURRENT" in + "FR on dev") NEXT="Ready for staging" ;; + "FR on staging") NEXT="Ready for prod" ;; + *) + echo "Item is in '$CURRENT' — /fr-pass only applies to 'FR on dev' or 'FR on staging'. Skipping." + echo "result=wrong-column" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + + NEXT_OPT=$(echo "$PROJ" | jq -r --arg s "$NEXT" \ + '.data.organization.projectV2.field.options[] | select(.name==$s) | .id') + + if [ -z "$NEXT_OPT" ] || [ "$NEXT_OPT" = "null" ]; then + echo "Could not resolve Status option '$NEXT' — aborting" + echo "result=missing-option" >> "$GITHUB_OUTPUT" + exit 1 + fi + + gh api graphql -f query=' + mutation($p: ID!, $i: ID!, $f: ID!, $o: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $p, itemId: $i, fieldId: $f, + value: {singleSelectOptionId: $o} + }) { projectV2Item { id } } + }' -F p="$PROJECT_ID" -F i="$ITEM_ID" -F f="$STATUS_FIELD" -F o="$NEXT_OPT" > /dev/null + + echo "→ #$NUMBER: $CURRENT → $NEXT" + echo "result=advanced" >> "$GITHUB_OUTPUT" + echo "from=$CURRENT" >> "$GITHUB_OUTPUT" + echo "to=$NEXT" >> "$GITHUB_OUTPUT" + + - name: React on the comment (👍 advanced / 👎 no-op) + if: always() && steps.advance.outputs.result != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_FULL: ${{ github.repository }} + COMMENT_ID: ${{ github.event.comment.id }} + RESULT: ${{ steps.advance.outputs.result }} + run: | + if [ "$RESULT" = "advanced" ]; then + REACTION="+1" + else + REACTION="-1" + fi + gh api -X POST "/repos/$REPO_FULL/issues/comments/$COMMENT_ID/reactions" \ + -f content="$REACTION" > /dev/null || true diff --git a/.github/workflows/kanban-closure-caller.yml b/.github/workflows/kanban-closure-caller.yml new file mode 100644 index 0000000..0b54df5 --- /dev/null +++ b/.github/workflows/kanban-closure-caller.yml @@ -0,0 +1,12 @@ +name: Kanban closure routing + +on: + pull_request: + types: [closed] + issues: + types: [closed] + +jobs: + route: + uses: tracebloc/.github/.github/workflows/kanban-closure-router.yml@main + secrets: inherit diff --git a/.github/workflows/kanban-closure-router.yml b/.github/workflows/kanban-closure-router.yml index bd6b854..b2a775e 100644 --- a/.github/workflows/kanban-closure-router.yml +++ b/.github/workflows/kanban-closure-router.yml @@ -2,14 +2,21 @@ name: Route kanban Status on closure # Reusable workflow. Called on PR closed + issue closed events. # Sets the correct Status based on what actually happened: -# - PR merged to develop/staging → Functional review (awaiting prod release) -# - PR merged to main/master → Done (shipped to prod) +# - PR merged to develop → FR on dev (functional review on dev environment) +# - PR merged to staging → FR on staging (functional review on staging environment) +# - PR merged to main/master → Prod (shipped to prod) # - PR closed without merging → Cancelled # - Issue closed as completed: # · by a PR (any base) → mirror the PR's resulting Status -# · manually (no closing PR) → Done +# · manually (no closing PR) → Prod # - Issue closed as not_planned → Cancelled # - Issue closed (no state_reason) → Cancelled (default to abandoned) +# +# NOTE: For PR merges, advance-deploy-env.yml also fires on the resulting branch +# push and sets the same Status. Both workflows are idempotent and converge on +# the same value; this one fires faster (PR close event) and serves as the +# primary signal, while advance-deploy-env covers the case of pushes that +# weren't a PR merge (e.g. fast-forward of develop → staging). on: workflow_call: @@ -37,8 +44,10 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ]; then if [ "$PR_MERGED" = "true" ]; then case "$BASE_REF" in - main|master) echo "status=Done" >> "$GITHUB_OUTPUT" ;; - *) echo "status=Functional review" >> "$GITHUB_OUTPUT" ;; + main|master) echo "status=Prod" >> "$GITHUB_OUTPUT" ;; + staging) echo "status=FR on staging" >> "$GITHUB_OUTPUT" ;; + develop) echo "status=FR on dev" >> "$GITHUB_OUTPUT" ;; + *) echo "status=FR on dev" >> "$GITHUB_OUTPUT" ;; esac else echo "status=Cancelled" >> "$GITHUB_OUTPUT" @@ -46,7 +55,7 @@ jobs: elif [ "$EVENT_NAME" = "issues" ]; then if [ "$ISSUE_REASON" = "completed" ]; then # If a linked PR closed this issue, mirror that PR's resulting Status. - # Otherwise (manual completion), default to Done. + # Otherwise (manual completion), default to Prod. REPO_NAME="${REPO_FULL#*/}" CLOSING_PR_BASE=$(gh api graphql -f query=' query($org: String!, $repo: String!, $num: Int!) { @@ -58,9 +67,10 @@ jobs: }' -F org="$ORG" -F repo="$REPO_NAME" -F num="${{ github.event.issue.number }}" \ --jq '.data.repository.issue.closedByPullRequestsReferences.nodes[0].baseRefName // ""' 2>/dev/null) || CLOSING_PR_BASE="" case "$CLOSING_PR_BASE" in - main|master) echo "status=Done" >> "$GITHUB_OUTPUT" ;; - develop|staging) echo "status=Functional review" >> "$GITHUB_OUTPUT" ;; - *) echo "status=Done" >> "$GITHUB_OUTPUT" ;; + main|master) echo "status=Prod" >> "$GITHUB_OUTPUT" ;; + staging) echo "status=FR on staging" >> "$GITHUB_OUTPUT" ;; + develop) echo "status=FR on dev" >> "$GITHUB_OUTPUT" ;; + *) echo "status=Prod" >> "$GITHUB_OUTPUT" ;; esac else echo "status=Cancelled" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/wip-limit-check.yml b/.github/workflows/wip-limit-check.yml index 105a5ba..750dd34 100644 --- a/.github/workflows/wip-limit-check.yml +++ b/.github/workflows/wip-limit-check.yml @@ -23,8 +23,6 @@ on: jobs: check: runs-on: ubuntu-latest - permissions: - pull-requests: write steps: - name: Count items in Code review and comment if over limit env: