Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
62 changes: 55 additions & 7 deletions scripts/deploy-standard-workflows.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ declare -A SKIP_OVERRIDES=(
["add-to-project.yml"]=".github-private"
)

# Workflows deployable from standards/workflows/<name> verbatim.
# Excludes ci.yml, sonarcloud.yml (tech-stack-specific) and
# feature-ideation.yml (requires repo-specific project_context input).
# Workflows deployable from standards/workflows/<name>.
# Excludes only ci.yml and sonarcloud.yml (tech-stack-specific — set up manually).
# Most deploy verbatim (thin caller stubs, identical fleet-wide). feature-ideation
# is the exception: it carries a per-repo `project_context` edit, so it deploys
# SEED-IF-ABSENT and otherwise re-pins its OWN body in place — never overwriting
# the tuned body from the template. See BODY_PRESERVING_WORKFLOWS below.
DEPLOYABLE_WORKFLOWS=(
pr-review-mention.yml
dev-lead.yml
Expand All @@ -79,8 +82,34 @@ DEPLOYABLE_WORKFLOWS=(
dependabot-rebase.yml
dependency-audit.yml
add-to-project.yml
initiative-driver.yml
Comment thread
don-petry marked this conversation as resolved.
Resolved
pr-auto-review.yml
feature-ideation.yml
Comment thread
don-petry marked this conversation as resolved.
Resolved
)

# Deployable workflows whose stub BODY carries a documented per-repo edit the
# sweep must never clobber. feature-ideation's `project_context` (its only
# required per-repo customisation) is such an edit: re-syncing the body from the
# template would revert every repo's tuned context to the template default on
# every sweep — the config-loss footgun that forced #813's manual per-repo adds.
# A workflow listed here deploys SEED-IF-ABSENT: seeded from the template only
# when the stub is missing, and otherwise re-pinned in place from its OWN body
# (preserving project_context). Non-listed workflows keep the verbatim-overwrite
# re-sync (correct where the body is identical fleet-wide).
readonly BODY_PRESERVING_WORKFLOWS=(
feature-ideation.yml
)

# is_body_preserving_workflow <name.yml> -> 0 if the workflow's body must be
# preserved on re-deploy (seed-if-absent + re-pin-in-place).
is_body_preserving_workflow() {
local w="$1" p
for p in "${BODY_PRESERVING_WORKFLOWS[@]}"; do
[[ "$w" == "$p" ]] && return 0
done
return 1
}

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -255,10 +284,10 @@ deploy_repo() {
# ring-managed reusables the deployed template is REWRITTEN to pin the repo's
# tier channel (major-scoped `v<M>-<tier>` when the agent has a release) — the
# emit ref (#657 F5). Rewritten templates land in temp files cleaned up below.
local -a paths=() templates=() names=() emits=() tmpfiles=()
local workflow template target_path raw existing_sha existing_content emit deploy_template base repin_source
local -a paths=() templates=() names=() emits=() modes=() tmpfiles=()
local workflow template target_path raw existing_sha existing_content emit deploy_template base repin_source mode
for workflow in "${WORKFLOWS[@]}"; do
base=""; repin_source=""; emit=""; deploy_template=""
base=""; repin_source=""; emit=""; deploy_template=""; mode=""
template="$STANDARDS_DIR/$workflow"
target_path=".github/workflows/$workflow"
if [[ ! -f "$template" ]]; then
Expand Down Expand Up @@ -293,6 +322,21 @@ deploy_repo() {
repin_source="$(mktemp)"; tmpfiles+=("$repin_source")
printf '%s\n' "$existing_content" > "$repin_source"
fi
elif is_body_preserving_workflow "$workflow"; then
# feature-ideation carries a per-repo project_context the sweep must never
# clobber. Seed the generic template ONLY when the stub is absent; when it
# already exists, re-pin its OWN body in place (preserving project_context)
# — the same "re-pin OWN body" mechanism the meta-repo branch above uses,
# extended to regular target repos.
if [[ -z "$existing_sha" ]]; then
mode="seed"
else
mode="repin-in-place"
if [[ "$DRY_RUN" != "true" ]]; then
repin_source="$(mktemp)"; tmpfiles+=("$repin_source")
Comment thread
don-petry marked this conversation as resolved.
Resolved
printf '%s\n' "$existing_content" > "$repin_source"
fi
fi
fi

if [[ -n "$existing_sha" ]] && [[ "$FORCE" == "false" ]] && is_already_compliant "$existing_content" "$template" "$repo"; then
Expand All @@ -307,7 +351,7 @@ deploy_repo() {
ring_repin_uses "$base" "$emit" < "$repin_source" > "$deploy_template"
tmpfiles+=("$deploy_template")
fi
paths+=("$target_path"); templates+=("$deploy_template"); names+=("$workflow"); emits+=("$emit")
paths+=("$target_path"); templates+=("$deploy_template"); names+=("$workflow"); emits+=("$emit"); modes+=("$mode")
done

if [[ "${#names[@]}" -eq 0 ]]; then
Expand All @@ -323,6 +367,10 @@ deploy_repo() {
local i
for (( i = 0; i < n; i++ )); do
[[ -n "${emits[i]}" ]] && dry "$repo/${names[i]} would pin @${emits[i]}"
case "${modes[i]}" in
seed) dry "$repo/${names[i]} seed-if-absent: seeding fresh from template" ;;
repin-in-place) dry "$repo/${names[i]} re-pin uses in place — existing body/project_context preserved" ;;
esac
done
dry "Would open PR for $repo (branch $branch) — ${n} stub(s): $list"
rm -f "${tmpfiles[@]+"${tmpfiles[@]}"}"; return
Expand Down
4 changes: 2 additions & 2 deletions standards/workflows/feature-ideation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,15 @@ jobs:
# A workflow_dispatch fired with GITHUB_TOKEN is accepted but never starts
# a run (loop prevention), so a PAT is required (same as initiative-planner).
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}
run: |
if [ -z "${GH_TOKEN}" ]; then
echo "::error::GH_PAT_WORKFLOWS is required — a workflow_dispatch fired with GITHUB_TOKEN will not start a run."
exit 1
fi
- name: Re-dispatch under workflow_dispatch
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}
REPO: ${{ github.repository }}
DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
run: |
Expand Down
4 changes: 2 additions & 2 deletions standards/workflows/initiative-driver.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
- name: Guard — PAT present
# PAT required: a workflow_dispatch fired with GITHUB_TOKEN never starts a run.
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}
run: |
if [ -z "${GH_TOKEN}" ]; then
echo "::error::GH_PAT_WORKFLOWS is required — a workflow_dispatch fired with GITHUB_TOKEN never starts a run."
Expand All @@ -82,7 +82,7 @@ jobs:

- name: Dispatch central initiative-driver
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}
run: |
gh workflow run initiative-driver.yml \
-R petry-projects/.github-private \
Expand Down
5 changes: 4 additions & 1 deletion standards/workflows/pr-auto-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,7 @@ jobs:
actions: read
uses: petry-projects/.github/.github/workflows/pr-auto-review-reusable.yml@pr-auto-review/stable # NOSONAR(githubactions:S7637) first-party channel ref
secrets:
GH_PAT_WORKFLOWS: ${{ secrets.GH_PAT_WORKFLOWS }}
# Canonical-first fallback (.github-private#1326). The reusable resolves this
# secret BY NAME, so only the VALUE changes — the passed key stays named
# GH_PAT_WORKFLOWS.
GH_PAT_WORKFLOWS: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env bats
# Issue #836, Part B — the three newly-deployable standard workflows:
# initiative-driver.yml — self-contained dispatcher (no reusable) → verbatim add.
# pr-auto-review.yml — ring reusable → straight add, re-pinned to repo tier.
# feature-ideation.yml — ring reusable BUT carries a per-repo project_context,
# so it deploys SEED-IF-ABSENT + RE-PIN-IN-PLACE and must
# never overwrite an existing tuned body from the template.
#
# All runs are --dry-run (no mutating gh calls), single --repo/--workflow for
# determinism. A fake `gh` returns the reusable's release tags (major derivation)
# and, when set, the target repo's existing stub content.

setup() {
TT_TMP="$(mktemp -d "$BATS_TEST_TMPDIR/stub.XXXXXX")"
REPO_ROOT="$(cd -- "${BATS_TEST_DIRNAME}/../../.." && pwd)"
SCRIPT="${REPO_ROOT}/scripts/deploy-standard-workflows.sh"
}

teardown() { rm -rf "$TT_TMP"; }

# Fake gh:
# GH_RELEASE_REFS newline-separated `refs/tags/<agent>/vX.Y.Z` for matching-refs
# (unset/empty → no release → bare-tier form).
# GH_CONTENT_B64 base64 of the existing stub (unset → contents 404 = absent).
install_gh_stub() {
local bin="${TT_TMP}/bin"
mkdir -p "$bin"
cat > "$bin/gh" <<'STUB'
#!/usr/bin/env bash
if [ "${1:-}" = "api" ]; then
case "$2" in
*matching-refs/tags/*)
[ -n "${GH_RELEASE_REFS:-}" ] && printf '%s\n' "${GH_RELEASE_REFS}"
exit 0 ;;
*contents*)
if [ -n "${GH_CONTENT_B64:-}" ]; then
printf '{"sha":"abc123","content":"%s"}' "$GH_CONTENT_B64"
exit 0
fi
exit 1 ;; # simulate 404 — stub absent
esac
fi
exit 0
STUB
chmod +x "$bin/gh"
PATH="${bin}:${PATH}"; export PATH
}

# A feature-ideation stub with a TUNED project_context sentinel, pinned at <ref>.
fi_stub_pinning() { # <ref>
local body="jobs:
ideate:
uses: petry-projects/.github/.github/workflows/feature-ideation-reusable.yml@$1
with:
project_context: |
TUNED-REPO-CONTEXT-SENTINEL
secrets:
CLAUDE_CODE_OAUTH_TOKEN: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}"
base64 -w 0 <<<"$body" 2>/dev/null || base64 -b 0 <<<"$body"
}

# ── feature-ideation: SEED-IF-ABSENT ───────────────────────────────────────────

@test "feature-ideation: a repo WITHOUT the stub gets a fresh seed from the template" {
export GH_RELEASE_REFS="refs/tags/feature-ideation/v1.0.0"
install_gh_stub # no GH_CONTENT_B64 → 404 → stub absent
run env GH_TOKEN=x bash "$SCRIPT" --dry-run --repo markets --workflow feature-ideation.yml
[ "$status" -eq 0 ]
echo "$output" | grep -qF 'seed-if-absent: seeding fresh from template'
# markets is stable tier → v1 major line → @feature-ideation/v1-stable
echo "$output" | grep -qF '@feature-ideation/v1-stable'
echo "$output" | grep -qE 'Would open PR for markets .* feature-ideation.yml'
# must NOT take the re-pin-in-place path
! echo "$output" | grep -qF 're-pin uses in place'
}

# ── feature-ideation: RE-PIN-IN-PLACE (no clobber of project_context) ───────────

@test "feature-ideation: a repo WITH a tuned stub is re-pinned in place, body preserved" {
export GH_RELEASE_REFS="refs/tags/feature-ideation/v1.0.0"
# Existing tuned stub, still on the bare-tier channel. --force so the bare-tier
# migration grace does not short-circuit it as already-compliant.
GH_CONTENT_B64="$(fi_stub_pinning feature-ideation/stable)"; export GH_CONTENT_B64
install_gh_stub
run env GH_TOKEN=x bash "$SCRIPT" --dry-run --force --repo markets --workflow feature-ideation.yml
[ "$status" -eq 0 ]
echo "$output" | grep -qF 're-pin uses in place — existing body/project_context preserved'
echo "$output" | grep -qF '@feature-ideation/v1-stable'
echo "$output" | grep -qE 'Would open PR for markets .* feature-ideation.yml'
# must NOT take the seed path (that would overwrite project_context)
! echo "$output" | grep -qF 'seeding fresh from template'
}

@test "feature-ideation: an already-compliant tuned stub is left untouched (no PR)" {
# A stub already at the tier-correct v-form is compliant → no re-pin, no clobber.
export GH_RELEASE_REFS="refs/tags/feature-ideation/v1.0.0"
GH_CONTENT_B64="$(fi_stub_pinning feature-ideation/v1-stable)"; export GH_CONTENT_B64
install_gh_stub
run env GH_TOKEN=x bash "$SCRIPT" --dry-run --repo markets --workflow feature-ideation.yml
[ "$status" -eq 0 ]
echo "$output" | grep -q 'already compliant'
! echo "$output" | grep -q 'Would open PR'
}

# ── initiative-driver: verbatim add (no reusable → no pin) ──────────────────────

@test "initiative-driver: deploys verbatim to the fleet with no uses: pin" {
install_gh_stub # 404 → stub absent → verbatim seed
run env GH_TOKEN=x bash "$SCRIPT" --dry-run --repo markets --workflow initiative-driver.yml
[ "$status" -eq 0 ]
echo "$output" | grep -qE 'Would open PR for markets .* initiative-driver.yml'
# self-contained dispatcher: no reusable uses: → nothing to pin
! echo "$output" | grep -qF 'would pin @'
! echo "$output" | grep -qF 'seed-if-absent'
}

# ── pr-auto-review: ring add, re-pinned to the repo's tier ──────────────────────

@test "pr-auto-review: deploys pinned to the repo's ring tier" {
export GH_RELEASE_REFS="refs/tags/pr-auto-review/v2.1.0"
install_gh_stub # 404 → stub absent → seed + re-pin from template
run env GH_TOKEN=x bash "$SCRIPT" --dry-run --repo TalkTerm --workflow pr-auto-review.yml
[ "$status" -eq 0 ]
# TalkTerm is ring1 tier → v2 major line → @pr-auto-review/v2-ring1
echo "$output" | grep -qF '@pr-auto-review/v2-ring1'
echo "$output" | grep -qE 'Would open PR for TalkTerm .* pr-auto-review.yml'
# pr-auto-review body is identical fleet-wide — not body-preserving
! echo "$output" | grep -qF 'seed-if-absent'
! echo "$output" | grep -qF 're-pin uses in place'
}
47 changes: 47 additions & 0 deletions test/workflows/feature-ideation/stub-secrets.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bats
# Tests for the credential wiring of the canonical caller stub
# standards/workflows/feature-ideation.yml (issue #836, Part A).
#
# The GH_PAT_WORKFLOWS retirement (.github-private#1326) adds the canonical org
# secret GH_PAT_DON_PETRY, keeping the old name as a `||` transition fallback.
# The two redispatch-guard `GH_TOKEN:` refs must prefer GH_PAT_DON_PETRY.
# The reusable `secrets:` block passes CLAUDE_CODE_OAUTH_TOKEN (not the PAT) and
# MUST stay untouched.

load 'helpers/setup'

STUB="${TT_REPO_ROOT}/standards/workflows/feature-ideation.yml"

CHAIN='${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}'

@test "stub: redispatch PAT-present guard GH_TOKEN uses the canonical-first fallback" {
run yq -r '.jobs.redispatch.steps[] | select(.name == "Guard — PAT present") | .env.GH_TOKEN' "$STUB"
[ "$status" -eq 0 ]
[ "$output" = "$CHAIN" ]
}

@test "stub: re-dispatch step GH_TOKEN uses the canonical-first fallback" {
run yq -r '.jobs.redispatch.steps[] | select(.name == "Re-dispatch under workflow_dispatch") | .env.GH_TOKEN' "$STUB"
[ "$status" -eq 0 ]
[ "$output" = "$CHAIN" ]
}

@test "stub: no bare secrets.GH_PAT_WORKFLOWS runtime ref remains" {
run grep -nE 'secrets\.GH_PAT_WORKFLOWS' "$STUB"
[ "$status" -eq 0 ]
while IFS= read -r line; do
[[ "$line" == *"GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS"* ]]
done <<< "$output"
Comment thread
don-petry marked this conversation as resolved.
Resolved
}

@test "stub: reusable secrets block still passes CLAUDE_CODE_OAUTH_TOKEN unchanged" {
run yq -r '.jobs.ideate.secrets.CLAUDE_CODE_OAUTH_TOKEN' "$STUB"
[ "$status" -eq 0 ]
[ "$output" = '${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}' ]
}

@test "stub: reusable secrets block carries no GH_PAT ref (PAT is not passed to the reusable)" {
run yq -r '.jobs.ideate.secrets | keys | .[]' "$STUB"
[ "$status" -eq 0 ]
[ "$output" = 'CLAUDE_CODE_OAUTH_TOKEN' ]
}
40 changes: 40 additions & 0 deletions test/workflows/initiative-driver/stub-secrets.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bats
# Tests for the credential wiring of the canonical caller stub
# standards/workflows/initiative-driver.yml (issue #836, Part A).
#
# The GH_PAT_WORKFLOWS retirement (.github-private#1326) adds the canonical org
# secret GH_PAT_DON_PETRY, keeping the old name as a `||` transition fallback.
# The two `GH_TOKEN:` guard/dispatch refs must prefer GH_PAT_DON_PETRY.
# Referenced inside a `||` the canonical secret needs no workflow_call.secrets
# declaration (this stub is a self-contained dispatcher — no reusable).

TT_REPO_ROOT="$(cd -- "$(dirname -- "${BATS_TEST_DIRNAME}")/../.." && pwd)"
STUB="${TT_REPO_ROOT}/standards/workflows/initiative-driver.yml"
Comment thread
don-petry marked this conversation as resolved.
Resolved

CHAIN='${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}'

@test "stub: PAT-present guard GH_TOKEN uses the canonical-first fallback" {
run yq -r '.jobs.dispatch.steps[] | select(.name == "Guard — PAT present") | .env.GH_TOKEN' "$STUB"
[ "$status" -eq 0 ]
[ "$output" = "$CHAIN" ]
}

@test "stub: dispatch-step GH_TOKEN uses the canonical-first fallback" {
run yq -r '.jobs.dispatch.steps[] | select(.name == "Dispatch central initiative-driver") | .env.GH_TOKEN' "$STUB"
[ "$status" -eq 0 ]
[ "$output" = "$CHAIN" ]
}

@test "stub: no bare secrets.GH_PAT_WORKFLOWS runtime ref remains" {
# Every GH_PAT_WORKFLOWS occurrence must be inside the canonical-first chain.
run grep -nE 'secrets\.GH_PAT_WORKFLOWS' "$STUB"
[ "$status" -eq 0 ]
while IFS= read -r line; do
[[ "$line" == *"GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS"* ]]
done <<< "$output"
Comment thread
don-petry marked this conversation as resolved.
Resolved
}

@test "stub: adds no workflow_call.secrets block (self-contained dispatcher)" {
run yq -r '.on.workflow_call // "absent"' "$STUB"
[ "$output" = 'absent' ]
}
Loading
Loading