Repository navigation
feat: implement issue #478 — deploy standard workflows via PRs, not direct pushes #480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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/ |
| 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) | ||||||||||||||||||||||||||||||||||||||
| 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") | ||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The A fully portable, POSIX-compliant way to encode a file to base64 without line wrapping is to pipe the file content to
Suggested change
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the specified label (e.g., 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
|
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| echo "OPENED $pr_url" | ||||||||||||||||||||||||||||||||||||||
| return 0 | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| 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")" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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' | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When there are no open PRs,
gh pr listreturns an empty array[]. Running.[0].numberon an empty array causesjqto fail withjq: error: Cannot index null with string "number". Although the error is silenced with2>/dev/null, it causes the pipeline to exit with a non-zero status.Using the optional object index operator
?(e.g.,.[0].number?) avoids indexingnulland preventsjqfrom throwing an error.