Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 28 additions & 17 deletions .github/workflows/advance-deploy-env.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
15 changes: 15 additions & 0 deletions .github/workflows/fr-gate-caller.yml
Original file line number Diff line number Diff line change
@@ -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
166 changes: 166 additions & 0 deletions .github/workflows/fr-gate.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions .github/workflows/fr-pass-comment-caller.yml
Original file line number Diff line number Diff line change
@@ -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
Loading