Skip to content

fix(tag-release): use Git Data API so commits and tags auto-sign#56

Open
j7an wants to merge 10 commits into
mainfrom
fix/tag-release-signed-commits
Open

fix(tag-release): use Git Data API so commits and tags auto-sign#56
j7an wants to merge 10 commits into
mainfrom
fix/tag-release-signed-commits

Conversation

@j7an
Copy link
Copy Markdown
Owner

@j7an j7an commented May 15, 2026

Summary

  • Rewrite tag-release.yml's bump-commit step to use the GitHub Git Data API (POST /git/blobsPOST /git/treesPOST /git/commitsPATCH /git/refs/heads/main). The commit's parent is BASE_SHA=$GITHUB_SHA, not a fresh read of main, to avoid the silent-overwrite race documented in the spec.
  • Rewrite the annotated-tag step to use POST /git/tags + POST /git/refs against either the new bump commit's SHA or main's current HEAD (via API, never local checkout state).
  • Both API payloads omit author / committer / tagger fields so GitHub auto-signs with its bot/web-flow key — satisfying the required_signatures branch ruleset on consumer repos without bypass-actor entries.
  • Add /tmp/bump.modified manifest emission (unique path set) to scripts/bump-version-files.sh so the API path knows which files to put in the tree.
  • Add bats tests for manifest contract (4 tests) and §2 design invariant (4 tests).

The verification check uses the low-level GET /git/commits/{sha} endpoint with jq path .verification.verified (top-level), not the higher-level /commits/{sha} endpoint with .commit.verification.verified — a distinction that bit us during review and is now documented in-line.

See the design notes in the spec authored alongside this PR (kept locally per the project's docs/ workflow; not committed) for the full reasoning behind the design invariant, race-detection backstop, and acceptance criteria.

Test plan

  • bash scripts/check-inline-sync.sh passes
  • bash scripts/lint-workflow-call.sh passes
  • bats tests/bump-version-files.bats — 50/50 (existing 46 + 4 new manifest tests)
  • bats tests/tag-release-invariants.bats — 4/4
  • Full bats tests/ suite — 92/92
  • All 4 workflow YAML files parse via python3 yaml.safe_load(...)
  • No git config user.*, git tag -a, or git push origin patterns remain in tag-release.yml
  • Commit-subject audit: 5 × fix: + 3 × test:, 0 × feat: (no inadvertent minor-bump trigger)
  • After merge: cut v2.5.3-rc1 (manual — release.yml is workflow_call only, not push-driven), pin cross-agent-reviews to RC SHA, run Tag Release on cross-agent-reviews via workflow_dispatch, verify acceptance criteria deps: bump step-security/harden-runner from 2.16.0 to 2.16.1 #1fix: use PR author instead of github.actor for bot detection #7 from spec §6
  • After canary success: promote v2.5.3 from RC SHA (manual replay of release.yml's two steps — floating v2/v2.5 tag updates + gh release create v2.5.3), bump cross-agent-reviews to v2.5.3 final

Fixes j7an/cross-agent-reviews#12.

j7an added 10 commits May 14, 2026 22:30
These tests are intentionally failing — they pin the contract that the
Git Data API rewrite of tag-release.yml will consume. The manifest must
be a unique path set so POST /git/trees does not receive duplicate
tree[] entries.

Refs cross-agent-reviews#12.
…phase pass

Code-quality review found that Test 4 (no cross-run pollution) and Test 2
(unique-paths in mixed run) could pass for the wrong reason during the
red phase if /tmp/bump.modified didn't exist:

  - Test 4: `! grep -q server.json /tmp/bump.modified` returned true on a
    missing file (grep fails, ! flips), masking that the script wasn't
    creating the manifest at all. Add explicit `[ -f /tmp/bump.modified ]`
    guard before the negated grep.
  - Test 2: `actual=$(sort /tmp/bump.modified)` swallowed the missing-file
    error into an empty `actual`, producing a content-mismatch message
    that hid the real problem. Switch to `< /tmp/bump.modified` so the
    shell-level open fails loudly with No such file or directory.

Also clarify Test 3's confusing inline comment about idempotent rerun.

Refs cross-agent-reviews#12.
Manifest of modified paths consumed by tag-release.yml's Git Data API
rewrite. De-duped via sort -u so multi-entry-same-file configs (two
path_expr entries against one file) produce one path, not two — which
would create duplicate tree[] entries in POST /git/trees.

Refs cross-agent-reviews#12.
Mirror the manifest-emission changes from scripts/bump-version-files.sh
into the inline copy embedded in tag-release.yml, satisfying the
inline-sync invariant validated by scripts/check-inline-sync.sh.

Refs cross-agent-reviews#12.
Static checks that the rewritten Bump and Tag steps do not regress to
local-state reads (which would reintroduce the silent-overwrite race
the API rewrite is designed to prevent) and that bot-signing-disabling
identity fields stay out of API payloads.

Phase B-only checks (GITHUB_SHA usage, no identity fields) skip on the
current Phase A baseline and activate after the workflow rewrite lands.

Refs cross-agent-reviews#12.
Replace `git commit && git push origin HEAD:main` with a sequence of
gh api calls: blobs → tree → commit → PATCH ref. The commit's parent is
BASE_SHA=$GITHUB_SHA (the runner's checkout SHA), not a fresh read of
main, to avoid the silent-overwrite race documented in spec §2.

The commit payload omits author/committer fields so GitHub auto-signs
with its bot key (spec §3.3) — satisfying the required_signatures
branch ruleset on consumer repos like cross-agent-reviews without
needing bypass-actor entries.

The exit-2 branch (no bump created) reads main HEAD via API and exports
TAG_TARGET_SHA for the tag step.

Fixes cross-agent-reviews#12.
Code-quality review against GitHub's REST API docs revealed the plan and
spec had inherited a wrong jq path: GET /repos/.../git/commits/{sha}
returns the raw Git commit object with `verification` at the TOP level,
not under `.commit.verification` (that nesting only exists on the higher-
level GET /repos/.../commits/{sha} endpoint).

With the wrong path, $VERIFIED would always be the literal string "null"
and the acceptance-criterion-#2 check would always fail with exit 1
AFTER the bump commit had already been PATCH'd onto main — leaving a
dangling commit and no tag.

Fix: keep the low-level /git/commits/{sha} endpoint (consistent with the
PARENT_TREE_SHA call earlier in the same step) but use .verification.*
jq paths instead. Add a comment documenting the gotcha so future
maintainers don't reintroduce it.

Refs cross-agent-reviews#12.
Replace `git tag -a && git push origin <tag>` with POST /git/tags +
POST /git/refs. Tag points at TAG_TARGET_SHA exported by the prior
Bump step (new commit SHA in the happy path; current main HEAD via
API in the no-bump case).

Tag payload omits the tagger field so GitHub auto-signs the tag object
with its bot key when possible. The verification.verified status is
reported in the workflow step summary so the canary run can record
acceptance criterion #3 (signed) vs #4 (unsigned-fallback) per spec §6.

Fixes cross-agent-reviews#12.
Three review findings on the signed-commit release path:

- Finding 2: the case-0 PATCH to refs/heads/main now runs *after* the
  verification check, not before. A commit object is addressable by
  SHA the instant POST /git/commits returns, so verification needs no
  ref update first. Verifying first means a verification failure
  leaves main untouched — no dangling unverified commit for a later
  rerun to pick up.

- Finding 1: the no-bump (exit 2) path now tags GITHUB_SHA — the
  commit "Compute release" semver-analyzed — instead of a live
  GET /git/ref/heads/main read. The live read raced with concurrent
  pushes (spec §2) and could tag an unanalyzed commit. GITHUB_SHA
  also covers rerun-after-partial-failure: a fresh dispatch resolves
  it to current main HEAD anyway.

- Finding 4: manifest path is now configurable via BUMP_MODIFIED_FILE
  (default unchanged) so concurrent script/test runs don't clobber a
  shared /tmp file. The bats suite pins a per-test path; the inline
  copy stays in sync.
Finding 3: the parent-SHA invariant test previously only checked that
GITHUB_SHA appeared somewhere in the bump step — a stray echo would
satisfy it. It now asserts the full load-bearing chain:
GITHUB_SHA -> BASE_SHA -> jq --arg parent -> commit payload.

Adds a companion guard (Finding 1 regression): the bump step must not
perform a live `GET .../git/ref/heads/` read. The singular-`ref` GET
form is forbidden; the legitimate fast-forward PATCH uses the plural
`git/refs/heads/main` and is unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tag Release fails: signed-commits rule rejects unsigned bot push

1 participant