Skip to content

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

Merged
j7an merged 12 commits into
mainfrom
fix/tag-release-signed-commits
May 16, 2026
Merged

fix(tag-release): use Git Data API so commits and tags auto-sign#56
j7an merged 12 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

Refs j7an/cross-agent-reviews#12. Not auto-closed on merge: cross-agent-reviews must still adopt the resulting v2.5.3 tag release (the two unchecked post-merge items above) before #12 is fully resolved.

j7an added 12 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.
The tag step comment claimed case 2 reads refs/heads/main via
GET /git/ref/heads/main; the implementation exports GITHUB_SHA (the
semver-analyzed commit). Correct the comment so it cannot mislead a
maintainer into reintroducing the live-ref race.

Add a RECOVERY NOTE next to the main-advancing PATCH: after a partial
failure (bump pushed, tag not yet created), GitHub's "Re-run failed
jobs" 422s because it recreates a sibling bump commit; recovery is a
fresh workflow_dispatch. Documents review Finding 1 as an accepted,
signposted limitation.

Refs #56
The RECOVERY NOTE claimed a fresh workflow_dispatch unconditionally
takes the no-bump path. That holds only if the recovery run reuses the
original bump and tag-prefix inputs: "Compute release" derives the
version from the last tag (unchanged — the tag was never created), so
an explicit bump override or a different tag-prefix recomputes a
different version and produces a second, wrong bump commit.

Correct the comment to state the input-reuse requirement and why, and
surface the same guidance at runtime: case 0 (the only path that
advances main) now prints a partial-failure recovery hint into the
step summary, visible on a failed run without reading the workflow.

Refs #56
@j7an j7an merged commit f599424 into main May 16, 2026
7 checks passed
@j7an j7an deleted the fix/tag-release-signed-commits branch May 16, 2026 04:26
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.

1 participant