From 265d12454876b28b15e78dac01d5b67aad79eaa6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:54:54 +0100 Subject: [PATCH] feat(actions): add signed-push composite action (verified commits via GitHub App) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estate primitive for the "AI tools can't operate GitHub" problem: automation commits made with a PAT/GITHUB_TOKEN are Unverified, so any branch requiring signed commits (`required_signatures`) is BLOCKED from merging (e.g. IDApTIK #28). signed-push mints a GitHub App installation token (actions/create-github-app-token) and delegates to Asana/push-signed-commits, which replays local commits through the createCommitOnBranch GraphQL mutation — GitHub signs those itself, so they land as Verified and satisfy the ruleset. - SHA-pinned: create-github-app-token v3.2.0, push-signed-commits v1. - README documents App setup + the ruleset bypass caveat for PR-only/merge-queue. BLOCKED (draft): requires the org to register the App and set APP_ID / APP_PRIVATE_KEY secrets before it can run/verify. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/signed-push/README.md | 63 ++++++++++++++++++++++++++ .github/actions/signed-push/action.yml | 46 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 .github/actions/signed-push/README.md create mode 100644 .github/actions/signed-push/action.yml diff --git a/.github/actions/signed-push/README.md b/.github/actions/signed-push/README.md new file mode 100644 index 00000000..ee0c65af --- /dev/null +++ b/.github/actions/signed-push/README.md @@ -0,0 +1,63 @@ + +# `signed-push` — verified commits from CI/AI tooling + +Estate composite action that pushes a branch's local commits as GitHub **Verified** +commits, so they satisfy `required_signatures` branch rulesets. + +## Why + +Commits made by automation with a **PAT** or the default `GITHUB_TOKEN` show as +**Unverified**, and any branch whose ruleset requires signed commits then **cannot +merge** (e.g. `mergeStateStatus: BLOCKED`). The only reliable way for automation to +produce Verified commits is the GraphQL [`createCommitOnBranch`] mutation +authenticated as a **GitHub App installation** — GitHub signs those commits itself. + +This action mints an App installation token and delegates the push to +[`Asana/push-signed-commits`], which diffs local vs. remote and replays the local +commits through `createCommitOnBranch`. + +## Prerequisites (one-time, account owner) + +1. Register a **GitHub App** on the org (Settings → Developer settings → GitHub Apps). + Repository permissions: **Contents: Read & write**, **Workflows: Read & write** + (to edit `.github/workflows`), optionally **Administration: Read & write** (for + `delete_repo`). +2. **Install** the App on the org/repos and **generate a private key** (`.pem`). +3. Store **`APP_ID`** and **`APP_PRIVATE_KEY`** as org (or repo) secrets. +4. If the branch ruleset also enforces **pull-request-only merges** or a **merge + queue**, add the App to that ruleset's **bypass list** — `required_signatures` + is satisfied by this action, but those are separate rules. + +## Usage + +```yaml +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - name: Make changes + run: | + echo "generated" >> CHANGELOG.md + git config user.name "estate-bot[bot]" + git config user.email "estate-bot[bot]@users.noreply.github.com" + git commit -am "chore: regenerate changelog" + - name: Push as Verified + uses: hyperpolymath/standards/.github/actions/signed-push@main + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + # branch: defaults to the current ref +``` + +## Notes + +- Pinned to SHAs (`create-github-app-token` v3.2.0, `push-signed-commits` v1) per + estate supply-chain policy. +- Run **after** local commits exist in the job — the action pushes the delta + between your local branch and its remote. + +[`createCommitOnBranch`]: https://github.blog/changelog/2021-09-13-a-simpler-api-for-authoring-commits/ +[`Asana/push-signed-commits`]: https://github.com/Asana/push-signed-commits diff --git a/.github/actions/signed-push/action.yml b/.github/actions/signed-push/action.yml new file mode 100644 index 00000000..eb31586f --- /dev/null +++ b/.github/actions/signed-push/action.yml @@ -0,0 +1,46 @@ +name: Signed push (verified commits via GitHub App) +description: > + Re-push the current branch's local commits as GitHub-"Verified" commits using a + GitHub App installation token and the createCommitOnBranch GraphQL mutation (via + Asana/push-signed-commits). This satisfies `required_signatures` branch rulesets + that a PAT or the default GITHUB_TOKEN cannot. Run this AFTER you have made local + commits in the job (checkout -> edit -> git commit -> this action). + +inputs: + app-id: + description: GitHub App ID (pass from a repository/organization variable or secret). + required: true + private-key: + description: GitHub App private key, PEM contents (pass from a secret). + required: true + branch: + description: Branch to push as verified (local name == remote name). Defaults to the current ref. + required: false + default: '' + +runs: + using: composite + steps: + - name: Mint GitHub App installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + + - name: Resolve branch name + id: branch + shell: bash + env: + BRANCH_INPUT: ${{ inputs.branch }} + run: echo "name=${BRANCH_INPUT:-$GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + + # createCommitOnBranch extracts authorship from the App installation credential + # and auto-marks the resulting commits as "Verified". A PAT would leave them + # Unverified — which is why this must be an App token, not GITHUB_TOKEN/PAT. + - name: Push local commits as Verified + uses: Asana/push-signed-commits@6e073ae68b04d4322be613a6cabf391acb03ca44 # v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + local_branch_name: ${{ steps.branch.outputs.name }} + remote_branch_name: ${{ steps.branch.outputs.name }}