Skip to content
Merged
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
160 changes: 113 additions & 47 deletions .github/workflows/promote-baseline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,30 @@
# pineforge-engine/.github/workflows/promote-baseline.yml
# pineforge-codegen-oss/.github/workflows/promote-baseline.yml
#
# On a merged PR it advances the campaign baseline one axis to the merged
# composite, reusing the snapshot from the pr-gate run that already cleared
# it. It promotes ONLY when the merge is exact-head (the gated commit's tree is
# now on the base branch), CI is green on that commit, and a PASS verdict binds
# the full (engine, codegen) pair. Otherwise it exits green without promoting.
# On a merged PR it advances the campaign baseline one axis, reusing the
# snapshot the gate already measured. It promotes ONLY when the merge commit
# now on the base branch carries the gated PR head's TREE (a squash merge mints
# a new commit; the strict up-to-date rule keeps its tree the head's), the gate
# is green on that head, and `lab promote` finds the verdict that binds it:
# engine pineforge/verify and pineforge/parity success on the PR head;
# lab promote adopts the pair the merge gate's parity verdict
# measured — the merged engine with the codegen the sweep measured
# (ruling R2, 2026-09-26), PASS or neutral alike
# codegen the required `gate` check-run success and no failing check-run on
# the PR head; lab promote needs a pr-gate PASS binding (baseline
# engine, the PR head) and re-reads both trees from GitHub (the
# verdict records commits, not trees; GH_TOKEN below)
# Otherwise it exits green without promoting.
#
# The gate logic (exact-head + CI-green + baseline-promote.mjs) is unchanged;
# only the transport to the campaign plane changed. The plane is now GCP: the
# It runs on `pull_request_target`: MAIN's own copy of this file (ref
# refs/heads/main), never the PR's. The promotion identity's workload-identity
# condition admits only `<repo>/.github/workflows/promote-baseline.yml@refs/heads/main`
# in a run of refs/heads/main (terraform/gcp/github-wif.tf), so a pull request
# that edits this file cannot mint it — and a `pull_request` trigger, which
# runs the PR's copy, mints nothing at all. It checks out and runs no PR code:
# it fetches the PR head's objects only to compare trees.
#
# The plane is GCP: the
# promotion reads the active baseline and the gate ledger from the Postgres
# registry through a cloud-sql-proxy, streams the pinned documents from the
# GCS evidence bucket, and appends the new baseline row — authenticated by
Expand Down Expand Up @@ -39,27 +55,32 @@
# Cloud Run role: this workflow executes no job. Nothing is left for
# an operator to grant by hand.
#
# This file's PATH is part of the identity: the WIF provider admits only
# `<owner>/<repo>/.github/workflows/promote-baseline.yml@…`, so installing it
# under another name mints no token — and no other workflow in the repo can
# mint one either.
# This file's PATH and REF are part of the identity: the WIF provider admits
# only `<owner>/<repo>/.github/workflows/promote-baseline.yml@refs/heads/main`,
# so installing it under another name, or running another branch's copy,
# mints no token — and no other workflow in the repo can mint one either.
#
# (Optionally pin WORKFLOW_REF, defaults to main; and PINEFORGE_EVIDENCE_BUCKET,
# defaults to pineforge-workflow-evidence.)

name: promote-baseline

on:
pull_request:
# Main's own copy of this workflow, with the merged PR's payload
# (merge_commit_sha, head.sha, number, html_url, merged_at).
pull_request_target:
types: [closed]
# Manual re-fire for a merge whose pull_request event already passed (e.g. a
# PR merged before this workflow was installed, or a rebase-merge whose PR
# head was orphaned): pass the exact commit now on the base branch.
# Manual re-fire, from main, for a merge whose event already passed (e.g. a
# PR merged before this workflow was installed): the commit now on the base
# branch, the PR head the gate cleared, and the PR.
workflow_dispatch:
inputs:
merge_commit:
description: "40-hex commit now on the base branch to promote"
required: true
head_sha:
description: "40-hex PR head the gate cleared (the merge commit must carry its tree)"
required: true
pr_number:
description: "the PR number"
required: true
Expand All @@ -72,9 +93,9 @@ on:

permissions:
contents: read
# "Confirm CI is green" reads check-runs, which is `checks`. The older
# combined-status API is not called, so no `statuses: read` is granted --
# this block is exactly what the steps below do, and nothing spare.
# The engine's gate is two commit statuses (pineforge/verify,
# pineforge/parity): `statuses`. The codegen axis reads check-runs: `checks`.
statuses: read
checks: read
# Workload identity federation mints the GCP credential from the job's own
# OIDC token; nothing here can promote without this repo's identity.
Expand All @@ -93,54 +114,89 @@ jobs:
*) echo "repo=engine" >> "$GITHUB_OUTPUT" ;;
esac

# The base branch, never the PR (pull_request_target checks out main).
- uses: actions/checkout@v4
with:
fetch-depth: 0

# Exact-head is the safety property: the snapshot describes the gated
# commit's tree, so we promote only if that tree is what is now on the
# base branch. A squash/rebase that rewrote the tree, or a base that
# moved on, is skipped — those must be re-gated.
- name: Verify exact-head
id: exacthead
# The safety property is the TREE: the snapshot describes the gated
# head's tree, so we promote only when the merge commit now on the base
# branch carries exactly that tree. A base that moved before the merge,
# or a rewrite, is skipped — those must be re-gated.
- name: Verify the merged tree is the gated tree
id: exacttree
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.merge_commit }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha || github.event.inputs.merge_commit }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.head_sha }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
BASE_REF: ${{ github.event.pull_request.base.ref || 'main' }}
run: |
set -euo pipefail
git fetch --quiet origin "$BASE_REF"
if ! git merge-base --is-ancestor "$HEAD_SHA" "origin/$BASE_REF"; then
echo "::notice::head $HEAD_SHA is not on origin/$BASE_REF — not an exact-head merge; skipping"
# The PR head's objects, to read its tree: fetched, never checked out or run.
git fetch --quiet origin "$BASE_REF" "+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head"
if ! git merge-base --is-ancestor "$MERGE_SHA" "origin/$BASE_REF"; then
echo "::notice::merge commit $MERGE_SHA is not on origin/$BASE_REF; skipping"
echo "ok=false" >> "$GITHUB_OUTPUT"; exit 0
fi
base_tree=$(git rev-parse "origin/$BASE_REF^{tree}")
merge_tree=$(git rev-parse "$MERGE_SHA^{tree}")
head_tree=$(git rev-parse "$HEAD_SHA^{tree}")
if [ "$base_tree" != "$head_tree" ]; then
echo "::notice::base tree != head tree — the base diverged from the gated tree; re-gate. skipping"
if [ "$merge_tree" != "$head_tree" ]; then
echo "::notice::merge commit tree $merge_tree != gated head tree $head_tree — the base moved before the merge; re-gate. skipping"
echo "ok=false" >> "$GITHUB_OUTPUT"; exit 0
fi
echo "merge_tree=$merge_tree" >> "$GITHUB_OUTPUT"
echo "ok=true" >> "$GITHUB_OUTPUT"

- name: Confirm CI is green on the merged commit
- name: Confirm the gate on the PR head
id: ci
if: steps.exacthead.outputs.ok == 'true'
if: steps.exacttree.outputs.ok == 'true'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.merge_commit }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.head_sha }}
AXIS: ${{ steps.axis.outputs.repo }}
run: |
set -euo pipefail
bad=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \
--jq '[.check_runs[] | select(.conclusion=="failure" or .conclusion=="cancelled" or .conclusion=="timed_out" or .conclusion=="action_required")] | length')
if [ "$bad" != "0" ]; then
echo "::notice::$bad failing check-run(s) on $HEAD_SHA — not green; skipping"
echo "green=false" >> "$GITHUB_OUTPUT"; exit 0
if [ "$AXIS" = engine ]; then
# The engine merge gate. GitHub lists newest statuses first; keep
# the first occurrence of each context across pages, so an older
# success cannot mask a newer failure or pending verdict.
statuses=$(gh api --paginate \
"repos/${{ github.repository }}/commits/$HEAD_SHA/statuses?per_page=100")
if ! jq -es '
reduce (.[][]) as $s ({};
if has($s.context) then . else .[$s.context] = $s.state end)
| .["pineforge/verify"] == "success"
and .["pineforge/parity"] == "success"
' <<< "$statuses" >/dev/null; then
echo "::notice::pineforge/verify and pineforge/parity must both be success on $HEAD_SHA — skipping"
echo "green=false" >> "$GITHUB_OUTPUT"; exit 0
fi
else
# Codegen has no merge-gate statuses: its required status is the
# `gate` check-run (ruleset "PineForge strict CI base"), and no
# other check-run may have failed. Every page is {total_count,
# check_runs}; slurp them into one list.
runs=$(gh api --paginate \
"repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs?per_page=100")
gate=$(jq -s '[.[].check_runs[] | select(.name == "gate" and .conclusion == "success")] | length' <<< "$runs")
if [ "$gate" = "0" ]; then
echo "::notice::the required gate check-run has not succeeded on $HEAD_SHA — not green; skipping"
echo "green=false" >> "$GITHUB_OUTPUT"; exit 0
fi
bad=$(jq -s '[.[].check_runs[] | select(.conclusion=="failure" or .conclusion=="cancelled" or .conclusion=="timed_out" or .conclusion=="action_required")] | length' <<< "$runs")
if [ "$bad" != "0" ]; then
echo "::notice::$bad failing check-run(s) on $HEAD_SHA — not green; skipping"
echo "green=false" >> "$GITHUB_OUTPUT"; exit 0
fi
fi
echo "green=true" >> "$GITHUB_OUTPUT"

# The campaign tooling lives in the private pineforge-workflow repo. Rather
# than a long-lived PAT, mint a short-lived, read-only token from the org
# App already installed here (the same app-id/private-key release.yml
# uses), scoped to just that one repo.
# uses), scoped to just that one repo and to reading its contents: without
# a permission-* input the token would carry every permission the App's
# installation has, and only the checkout below uses it.
- name: Mint an org App token to read the campaign tooling
id: app
if: steps.ci.outputs.green == 'true'
Expand All @@ -150,6 +206,7 @@ jobs:
private-key: ${{ secrets.PINEFORGE_APP_PRIVATE_KEY }}
owner: pineforge-4pass
repositories: pineforge-workflow
permission-contents: read

- name: Checkout campaign tooling (pineforge-workflow)
if: steps.ci.outputs.green == 'true'
Expand Down Expand Up @@ -188,7 +245,7 @@ jobs:
chmod +x /tmp/cloud-sql-proxy
/tmp/cloud-sql-proxy --port 5433 \
gen-lang-client-0864094636:asia-east1:pineforge-workflow-pg &
for i in $(seq 1 30); do
for _ in $(seq 1 30); do
if (exec 3<>/dev/tcp/127.0.0.1/5433) 2>/dev/null; then exit 0; fi
sleep 1
done
Expand All @@ -208,7 +265,13 @@ jobs:
# No metadata server on a GitHub runner; mint GCS tokens from the
# federated gcloud credential explicitly.
PINEFORGE_GCP_TOKEN_CMD: gcloud auth print-access-token
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.merge_commit }}
# A codegen squash merge: lab promote reads the gated head's and the
# merge commit's trees from GitHub itself (gh api …/git/commits/<sha>)
# with the job's own read-only token.
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha || github.event.inputs.merge_commit }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.head_sha }}
MERGE_TREE: ${{ steps.exacttree.outputs.merge_tree }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
PR_URL: ${{ github.event.pull_request.html_url || github.event.inputs.pr_url }}
MERGED_AT: ${{ github.event.pull_request.merged_at || github.event.inputs.merged_at }}
Expand All @@ -218,18 +281,21 @@ jobs:
set +e
node .workflow/campaign/src/baseline-promote.mjs \
--repo "${{ steps.axis.outputs.repo }}" \
--merge-commit "$HEAD_SHA" --head-sha "$HEAD_SHA" --ci-head "$HEAD_SHA" --ci-green \
--merge-commit "$MERGE_SHA" --head-sha "$HEAD_SHA" --merge-tree "$MERGE_TREE" \
--ci-head "$HEAD_SHA" --ci-green \
--pr-number "$PR_NUMBER" --pr-url "$PR_URL" --merged-at-ms "$ms"
code=$?
set -e
# Exit 2 is `refuse`: no PASS verdict binds this merge as a single-axis
# Exit 2 is `refuse`: no verdict binds this merge as a single-axis
# advance -- a cross-cutting engine+codegen change (gated as a composite
# the single axis cannot complete), an ungated merge, or a non-exact-head
# merge. Defer green, do not fail CI; a cross-cutting pair is promoted by
# the single axis cannot complete), an ungated merge, a codegen merge
# gated only neutral (lab promote takes a pr-gate PASS), a merge tree
# GitHub does not show as the gated head's, or an engine merge the
# diff rule cleared (nothing measured). Defer green, do not fail CI; a cross-cutting pair is promoted by
# hand with `lab promote --composite` once both repos merge (see the
# baseline-promote skill). Any other non-zero is a real error.
if [ "$code" = "2" ]; then
echo "::notice::baseline not promoted (deferred): no PASS verdict binds this merge as a single-axis advance. If this was a cross-cutting engine+codegen change, run the composite promote by hand once both repos are merged. Nothing was changed."
echo "::notice::baseline not promoted (deferred): no verdict binds this merge as a single-axis advance (see the lab promote refusal above). If this was a cross-cutting engine+codegen change, run the composite promote by hand once both repos are merged. Nothing was changed."
exit 0
fi
exit "$code"
Loading