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
71 changes: 71 additions & 0 deletions .github/workflows/standards-deploy-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Quality gates for the org-standard workflow deploy tooling.
#
# Triggered on any PR that touches:
# - scripts/deploy-standard-workflows.sh
# - scripts/lib/standards-deploy.sh
# - test/scripts/deploy-standard-workflows/**
# - test/scripts/lib/standards-deploy.bats
#
# Gates (all must pass before merge):
# 1. shellcheck — static analysis for the deploy script + shared lib
# 2. bats — unit tests for sd_deploy_via_pr() and the dry-run path
#
# Standard: the PR-based deploy primitive documented in
# scripts/lib/standards-deploy.sh (see petry-projects/.github#478).

name: Standards Deploy Tests

on:
pull_request:
paths:
- 'scripts/deploy-standard-workflows.sh'
- 'scripts/lib/standards-deploy.sh'
- 'test/scripts/deploy-standard-workflows/**'
- 'test/scripts/lib/standards-deploy.bats'
- '.github/workflows/standards-deploy-tests.yml'
push:
branches:
- main
paths:
- 'scripts/deploy-standard-workflows.sh'
- 'scripts/lib/standards-deploy.sh'
- 'test/scripts/deploy-standard-workflows/**'
- 'test/scripts/lib/standards-deploy.bats'
- '.github/workflows/standards-deploy-tests.yml'

permissions:
contents: read

concurrency:
group: standards-deploy-tests-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Lint and bats
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 1

- name: Install bats, shellcheck, and jq
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends bats shellcheck jq

- name: shellcheck
run: |
set -euo pipefail
shellcheck --severity=warning -x \
scripts/deploy-standard-workflows.sh \
scripts/lib/standards-deploy.sh

- name: Run bats suite
run: |
bats --print-output-on-failure \
test/scripts/lib/standards-deploy.bats \
test/scripts/deploy-standard-workflows/
87 changes: 47 additions & 40 deletions scripts/deploy-standard-workflows.sh
Original file line number Diff line number Diff line change
@@ -1,34 +1,50 @@
#!/usr/bin/env bash
# deploy-standard-workflows.sh — Push org-standard workflow stubs to all repos.
# deploy-standard-workflows.sh — Deploy org-standard workflow stubs to all repos.
#
# Reads canonical stubs from standards/workflows/ and upserts them into every
# repo in the petry-projects org via the GitHub Contents API. Only workflows
# that have a template in standards/workflows/ AND appear in the
# DEPLOYABLE_WORKFLOWS list below are eligible for deployment — tech-stack-
# specific workflows (ci.yml, sonarcloud.yml) must be set up manually.
# Reads canonical stubs from standards/workflows/ and deploys them into every
# repo in the petry-projects org by opening a pull request per repo (via the
# shared scripts/lib/standards-deploy.sh primitive). Only workflows that have a
# template in standards/workflows/ AND appear in the DEPLOYABLE_WORKFLOWS list
# below are eligible — tech-stack-specific workflows (ci.yml, sonarcloud.yml)
# must be set up manually.
#
# Deployment is PR-based, never a direct push to the default branch: a direct
# Contents-API push is rejected (HTTP 409) on repos whose ruleset enforces
# required status checks, and bypasses review/CI on the repos where it would
# succeed. Opening a PR works uniformly on protected and unprotected repos and
# leaves an auditable, CI-gated record. The PRs are labeled `standards-sync` and
# left for the normal review/auto-merge pipeline — this script never merges and
# never uses --admin. See petry-projects/.github#478.
#
# Usage:
# deploy-standard-workflows.sh [options]
#
# Options:
# --dry-run Print planned actions without making any changes.
# --dry-run Print planned actions without opening any PRs.
# --workflow <name.yml> Deploy only this workflow (default: all deployable).
# --repo <name> Target a single repo instead of all org repos.
# --force Re-deploy even if the file looks correct (re-syncs).
#
# Requirements:
# GH_TOKEN (or gh auth login) with repo scope.
# GH_TOKEN (or gh auth login) with repo scope (branch + PR creation).

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/standards-deploy.sh
source "$SCRIPT_DIR/lib/standards-deploy.sh"

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
ORG="petry-projects"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
STANDARDS_DIR="$REPO_ROOT/standards/workflows"

# Branch prefix + label for the PRs this script opens.
SYNC_BRANCH_PREFIX="standards-sync"
SYNC_LABEL="standards-sync"

# Repos exempt from blanket standard-workflow deployment.
# .github — self-host source of truth; its own callers use local refs
# (e.g. add-to-project.yml pins ./.github/...), which a
Expand Down Expand Up @@ -75,7 +91,7 @@ while [[ $# -gt 0 ]]; do
--workflow) TARGET_WORKFLOW="$2"; shift 2 ;;
--repo) TARGET_REPO="$2"; shift 2 ;;
-h|--help)
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
Expand Down Expand Up @@ -141,24 +157,11 @@ is_already_compliant() {
echo "$existing_content" | grep -qF "$expected_uses"
}

# Upsert a file via the GitHub Contents API. Handles both create and update.
upsert_file() {
local repo="$1" path="$2" template_path="$3" sha="$4"
# Use base64 with line-wrap disabled; -w 0 (GNU) falls back to -b 0 (BSD/macOS).
local encoded
encoded=$(base64 -w 0 "$template_path" 2>/dev/null || base64 -b 0 "$template_path")

local commit_msg="chore: sync org-standard ${path##*/} stub from petry-projects/.github"

local extra_args=()
[[ -n "$sha" ]] && extra_args+=(--raw-field "sha=$sha")

gh api "repos/$ORG/$repo/contents/$path" \
--method PUT \
--raw-field message="$commit_msg" \
--raw-field "content=$encoded" \
"${extra_args[@]}" \
--silent
# Build the PR body for a stub deployment.
pr_body_for() {
local workflow="$1"
printf 'Syncs the org-standard `%s` workflow stub from `%s/.github` (`standards/workflows/%s`), deployed verbatim.\n\nOpened by `scripts/deploy-standard-workflows.sh`; the stub is a thin caller and all behaviour lives in the reusable. See `standards/ci-standards.md`.\n\nThis PR is labeled `%s` and left for the normal review/auto-merge pipeline — the deploy script never merges directly.\n' \
"$workflow" "$ORG" "$workflow" "$SYNC_LABEL"
}

# ---------------------------------------------------------------------------
Expand All @@ -184,30 +187,34 @@ deploy_workflow_to_repo() {
return
fi

local branch="${SYNC_BRANCH_PREFIX}/${workflow%.yml}"
local title="chore: sync org-standard ${workflow} stub from ${ORG}/.github"

if [[ "$DRY_RUN" == "true" ]]; then
if [[ -n "$existing_sha" ]]; then
dry "Would update $repo/$target_path (non-compliant stub)"
dry "Would open PR to update $repo/$target_path (branch $branch)"
else
dry "Would create $repo/$target_path"
dry "Would open PR to create $repo/$target_path (branch $branch)"
fi
return
fi

if upsert_file "$repo" "$target_path" "$template" "$existing_sha"; then
if [[ -n "$existing_sha" ]]; then
ok "Updated $repo/$target_path"
else
ok "Created $repo/$target_path"
fi
else
err "Failed to upsert $repo/$target_path"
fi
local outcome
outcome=$(sd_deploy_via_pr "$ORG/$repo" "$target_path" "$template" \
"$branch" "$SYNC_LABEL" "$title" "$(pr_body_for "$workflow")") || true

case "$outcome" in
"OPENED "*) ok "$repo/$target_path — opened ${outcome#OPENED }" ;;
"SKIP_PR_OPEN "*) skip "$repo/$target_path — PR #${outcome#SKIP_PR_OPEN } already open" ;;
"FAILED "*) err "$repo/$target_path — ${outcome#FAILED }" ;;
*) err "$repo/$target_path — unexpected outcome: ${outcome:-<none>}" ;;
esac
}

# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
[[ "$DRY_RUN" == "true" ]] && log "DRY RUN — no files will be written"
[[ "$DRY_RUN" == "true" ]] && log "DRY RUN — no PRs will be opened"

# Resolve target repos using pagination (handles orgs with >100 repos).
declare -a REPOS
Expand Down
114 changes: 114 additions & 0 deletions scripts/lib/standards-deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# scripts/lib/standards-deploy.sh — shared primitive for deploying an
# org-standard file to a consumer repo via a pull request.
#
# This is the single deploy primitive used by deploy-standard-workflows.sh (and
# available to any other standards-sync tooling). It NEVER pushes to a repo's
# default branch directly and NEVER merges or uses --admin. Instead it:
#
# 1. checks for an already-open PR on the sync branch (idempotent skip),
# 2. creates (or reuses) a sync branch off the repo's default branch,
# 3. PUTs the verbatim file content onto that branch via the Contents API,
# 4. opens a labeled pull request.
#
# Why a PR and not a direct push: a direct Contents-API push to the default
# branch is rejected (HTTP 409) on repos whose ruleset enforces required status
# checks — the Contents API does not honor the org-admin ruleset bypass. A PR
# works uniformly on protected and unprotected repos, runs the repo's CI against
# the change, and leaves an auditable record. See petry-projects/.github#478.
#
# Requires `gh` authenticated with repo scope (branch + PR creation).
#
# shellcheck shell=bash

# Guard against double-sourcing.
if [ -n "${_STANDARDS_DEPLOY_SOURCED:-}" ]; then
return 0 2>/dev/null || true
fi
_STANDARDS_DEPLOY_SOURCED=1

# sd_deploy_via_pr <repo_slug> <path> <local_file> <branch> <label> <title> <body>
#
# repo_slug owner/name of the target repo (e.g. petry-projects/markets)
# path path of the file within the target repo
# local_file local file whose contents are deployed verbatim
# branch head branch to create/reuse for the PR
# label label applied to the opened PR (e.g. standards-sync)
# title commit message and PR title
# body PR body (markdown)
#
# Prints exactly one outcome token line to stdout:
# OPENED <pr-url> a PR was opened
# SKIP_PR_OPEN <pr-number> an open PR already exists on this branch
# FAILED <reason> a hard error occurred (reason is a short slug)
#
# Returns 0 for OPENED/SKIP_PR_OPEN, non-zero for FAILED. Emits no other stdout,
# so callers can capture the outcome with a command substitution.
sd_deploy_via_pr() {
local repo="$1" path="$2" local_file="$3" branch="$4" label="$5" title="$6" body="$7"

if [ ! -f "$local_file" ]; then
echo "FAILED missing-local-file"
return 1
fi

# 1. Idempotency — an open PR already exists for this head branch.
local existing_pr
existing_pr=$(gh pr list --repo "$repo" --head "$branch" --state open \
--json number --jq '.[0].number // ""' 2>/dev/null || true)
Comment on lines +57 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When there are no open PRs, gh pr list returns an empty array []. Running .[0].number on an empty array causes jq to fail with jq: error: Cannot index null with string "number". Although the error is silenced with 2>/dev/null, it causes the pipeline to exit with a non-zero status.

Using the optional object index operator ? (e.g., .[0].number?) avoids indexing null and prevents jq from throwing an error.

Suggested change
existing_pr=$(gh pr list --repo "$repo" --head "$branch" --state open \
--json number --jq '.[0].number // ""' 2>/dev/null || true)
existing_pr=$(gh pr list --repo "$repo" --head "$branch" --state open \
--json number --jq '.[0].number? // ""' 2>/dev/null || true)

if [ -n "$existing_pr" ]; then
echo "SKIP_PR_OPEN $existing_pr"
return 0
fi

# 2. Resolve the default branch and its tip SHA (branch point).
local default_branch base_sha
default_branch=$(gh api "repos/${repo}" --jq '.default_branch' 2>/dev/null || echo "main")
base_sha=$(gh api "repos/${repo}/git/ref/heads/${default_branch}" \
--jq '.object.sha' 2>/dev/null || true)
if [ -z "$base_sha" ]; then
echo "FAILED no-base-sha"
return 1
fi

# 3. Create the sync branch; reuse it if a prior run already created it.
if ! gh api "repos/${repo}/git/refs" --method POST \
--raw-field "ref=refs/heads/${branch}" \
--raw-field "sha=${base_sha}" --silent 2>/dev/null; then
if ! gh api "repos/${repo}/git/ref/heads/${branch}" --silent 2>/dev/null; then
echo "FAILED no-branch"
return 1
fi
fi

# 4. PUT the file verbatim onto the branch. Look up the blob SHA on the branch
# so this handles both create (absent → empty SHA) and update (drifted stub)
# regardless of whether the branch is fresh or reused.
local branch_sha encoded
branch_sha=$(gh api "repos/${repo}/contents/${path}?ref=${branch}" \
--jq '.sha // ""' 2>/dev/null || true)
encoded=$(base64 -w 0 "$local_file" 2>/dev/null || base64 -b 0 "$local_file")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

low

The -w 0 (GNU) and -b 0 (BSD/macOS) flags for base64 are platform-specific and can fail on environments with minimal or alternative base64 implementations (such as Alpine Linux or busybox).

A fully portable, POSIX-compliant way to encode a file to base64 without line wrapping is to pipe the file content to base64 and strip newlines using tr -d '\n'.

Suggested change
encoded=$(base64 -w 0 "$local_file" 2>/dev/null || base64 -b 0 "$local_file")
encoded=$(base64 < "$local_file" | tr -d '\n')


local put_args=(--method PUT
--raw-field "message=${title}"
--raw-field "content=${encoded}"
--raw-field "branch=${branch}")
[ -n "$branch_sha" ] && put_args+=(--raw-field "sha=${branch_sha}")

if ! gh api "repos/${repo}/contents/${path}" "${put_args[@]}" --silent 2>/dev/null; then
echo "FAILED put-failed"
return 1
fi

# 5. Open the PR.
local pr_url
pr_url=$(gh pr create --repo "$repo" --head "$branch" --base "$default_branch" \
--title "$title" --body "$body" --label "$label" 2>/dev/null || true)
if [ -z "$pr_url" ]; then
echo "FAILED pr-create-failed"
return 1
fi
Comment on lines +104 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If the specified label (e.g., standards-sync) does not exist in the target repository, gh pr create --label will fail completely, preventing the pull request from being opened.

To make the deployment robust across all repositories (even those where the label hasn't been created yet), it is safer to open the PR first and then apply the label in a separate step, ignoring any failures.

Suggested change
local pr_url
pr_url=$(gh pr create --repo "$repo" --head "$branch" --base "$default_branch" \
--title "$title" --body "$body" --label "$label" 2>/dev/null || true)
if [ -z "$pr_url" ]; then
echo "FAILED pr-create-failed"
return 1
fi
local pr_url
pr_url=$(gh pr create --repo "$repo" --head "$branch" --base "$default_branch" \
--title "$title" --body "$body" 2>/dev/null || true)
if [ -z "$pr_url" ]; then
echo "FAILED pr-create-failed"
return 1
fi
if [ -n "$label" ]; then
gh pr edit "$pr_url" --add-label "$label" 2>/dev/null || true
fi


echo "OPENED $pr_url"
return 0
}
64 changes: 64 additions & 0 deletions test/scripts/deploy-standard-workflows/dry-run.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bats
# Dry-run tests for scripts/deploy-standard-workflows.sh
#
# Exercises the rewired PR-based dry-run path end-to-end with a fake `gh`:
# - a missing stub → "Would open PR to create …"
# - a compliant stub → "already compliant" (no PR planned)
# A single --repo + --workflow keeps the run deterministic (no `gh repo list`),
# and --dry-run guarantees no mutating gh calls are made.

setup() {
TT_TMP="$(mktemp -d)"
REPO_ROOT="$(cd -- "${BATS_TEST_DIRNAME}/../../.." && pwd)"
SCRIPT="${REPO_ROOT}/scripts/deploy-standard-workflows.sh"
TEMPLATE="${REPO_ROOT}/standards/workflows/add-to-project.yml"
GH_CALLS="${TT_TMP}/gh-calls.log"; export GH_CALLS
: > "$GH_CALLS"
}

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

# Install a fake gh. GH_CONTENT_B64 unset → contents API 404s (file missing);
# set → contents API returns it as the file body (compliant-stub case).
install_gh_stub() {
local bin="${TT_TMP}/bin"
mkdir -p "$bin"
cat > "$bin/gh" <<'STUB'
#!/usr/bin/env bash
{ printf 'gh'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >> "$GH_CALLS"
if [ "${1:-}" = "api" ]; then
case "$2" in
*contents*)
if [ -n "${GH_CONTENT_B64:-}" ]; then
printf '{"sha":"abc123","content":"%s"}' "$GH_CONTENT_B64"
exit 0
fi
exit 1 ;; # simulate 404 — file absent
esac
fi
exit 0
STUB
chmod +x "$bin/gh"
PATH="${bin}:${PATH}"; export PATH
}

@test "dry-run plans a PR to create a missing stub" {
install_gh_stub
run env GH_TOKEN=x bash "$SCRIPT" --dry-run --repo markets --workflow add-to-project.yml
[ "$status" -eq 0 ]
echo "$output" | grep -q 'DRY RUN — no PRs will be opened'
echo "$output" | grep -q 'Would open PR to create markets/.github/workflows/add-to-project.yml (branch standards-sync/add-to-project)'
# dry-run must not mutate: no branch creation, no PUT, no PR create
! grep -q 'git/refs --method POST' "$GH_CALLS"
! grep -q 'gh pr create' "$GH_CALLS"
}

@test "dry-run skips a stub that is already compliant" {
GH_CONTENT_B64="$(base64 -w 0 "$TEMPLATE" 2>/dev/null || base64 -b 0 "$TEMPLATE")"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

low

Use the portable base64 < file | tr -d '\n' pattern here as well to ensure the tests run reliably across different platforms (e.g., macOS and Linux CI runners) without relying on platform-specific flags.

  GH_CONTENT_B64="$(base64 < "$TEMPLATE" | tr -d '\n')"

export GH_CONTENT_B64
install_gh_stub
run env GH_TOKEN=x bash "$SCRIPT" --dry-run --repo markets --workflow add-to-project.yml
[ "$status" -eq 0 ]
echo "$output" | grep -q 'already compliant'
echo "$output" | grep -vq 'Would open PR'
}
Loading
Loading