Skip to content
Open
Show file tree
Hide file tree
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
34 changes: 34 additions & 0 deletions docs/gh-post.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,40 @@ After the identity check, the command reads the target's labels, and reads the r

Support covers repositories whose owner-review label doesn't start with `change:`, and the command doesn't look up a repository's configuration to check. Because no devtools gate treats a `change:` label as an authority, every gate stays in place. A consumer that sets a `change:` label as its owner-review label falls outside this support, because an agent could then clear that hold.

### Rerun failed CI jobs

Use this exact command form to request a rerun of failed jobs in one workflow run:

```bash
gh-post run rerun-failed 123456789 --repo owner/project
```

Supply a positive decimal run ID without leading zeros, followed by `--repo` exactly as shown. Go's command allowlist rejects aliases, reordered or extra arguments, body flags, job selectors, full reruns, and cancellation. The command leaves stdin unread. Before reading the run or requesting a rerun, the existing machine `post_identities` guard verifies the caller. Returned run ID and repository URL must match the requested target.

Only status `completed` with conclusion `failure`, `cancelled`, or `timed_out` qualifies. A state refusal exits 1 and prints status, conclusion, and attempt.

An eligible request sends one POST to `/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs`. Exit 0 prints the run URL after GitHub accepts it, without waiting for CI.

#### Attempt reservations

Before the POST, the command atomically creates and syncs a receipt under `${XDG_STATE_HOME:-~/.local/state}/devtools/gh-post-reruns/{owner}/{repo}/{run_id}/{run_attempt}`. It stores repository names in lowercase. Processes sharing that directory can't reserve the same attempt twice. A duplicate exits 1 without a POST. Once a later attempt completes with an eligible conclusion, the command can reserve it independently.

Windows doesn't allow syncing a directory handle, so the receipt file's own sync is the strongest durability available there.

Keep these receipts. They remain after success, failure, interruption, or audit failure. Removing one removes its duplicate protection.

Before writing to GitHub, the command appends and syncs an audit record with UTC time, operation `run rerun-failed`, canonical run URL, `run_attempt`, and outcome `reserved`. This records intent, including requests that GitHub never receives. Audit fields omit body text and credentials. A reservation or audit failure prevents the POST and exits 2.

#### Uncertain outcomes and guards

If a write fails, the command reads the run back once, reports status, conclusion, and attempt (or a read failure), and exits 2. It never repeats the POST, polls, or removes the receipt. Diagnostics omit raw GitHub errors and response bodies.

Inspect the run before further action. An accepted request can take time to appear.

Receipts coordinate only callers sharing the state directory. GitHub doesn't support a conditional attempt parameter or a duplicate-request key for this endpoint. Another host or external client can race between read and POST. A crash after reservation can withhold a request GitHub never receives. Keeping the receipt prevents automatic repeats in either case.

The PR skill's shell guard governs raw `gh pr` commands and already permits `gh-post`. The machine posting hook also permits the wrapper by basename. Both keep their existing restrictions. The Go parser admits the exact new run form without shell changes.

## Results and audit

| Exit | Meaning |
Expand Down
5 changes: 4 additions & 1 deletion internal/ghpost/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
)

var errUsage = errors.New(
"expected issue create|comment|edit|label|close|reopen, pr create|edit|comment|review|ready|close|label, comment edit, review edit, or release create|edit; use only supported flags and file input",
"expected issue create|comment|edit|label|close|reopen, pr create|edit|comment|review|ready|close|label, comment edit, review edit, release create|edit, or run rerun-failed <run-id> --repo owner/repo; use only supported flags and file input",
)

type request struct {
Expand Down Expand Up @@ -58,6 +58,9 @@ const (
)

func parse(args []string) (request, error) {
if len(args) > 0 && args[0] == "run" {
return parseRerunFailed(args)
}
r := request{}
if len(args) < operationWords {
return r, errUsage
Expand Down
10 changes: 9 additions & 1 deletion internal/ghpost/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Global flags:
--help Show all operations and flags without network access.
--check-deps Check git and gh on PATH without executing them.

All operations accept -R, --repo OWNER/REPO.
Operations accept -R, --repo OWNER/REPO unless an exact form is shown.
Content operations accept --body-file PATH and default to stdin;
--body-file - also reads stdin. PR ready and close never read stdin.
An issue edit with --type reads no stdin unless --body-file is given.
Expand Down Expand Up @@ -47,6 +47,14 @@ Operations and additional flags:
review edit <pr> <review-id>
release create <tag> --title TEXT --target REF --draft --prerelease --verify-tag
release edit <tag> --title TEXT --draft --prerelease
run rerun-failed <run-id> --repo OWNER/REPO

Run rerun-failed accepts exactly that form, with a positive decimal run ID,
and reads no stdin. Only completed failure, cancelled or timed_out runs
qualify. It reserves each run attempt in machine state before one failed-jobs
POST and audits the reservation. A repeated attempt is refused. After an
uncertain write it reads the run once, exits 2, and never retries the write.
It never reruns every job, cancels a run, or polls. Keep the state receipts.

New reviews require a 40-hex --commit matching the current PR head and
first body line Reviewed head: <sha> (the SHA may be wrapped in backticks).
Expand Down
167 changes: 167 additions & 0 deletions internal/ghpost/rerun.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Tony Burns

package ghpost

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)

const runRerunFailed = "run rerun-failed"

var errRerunReserved = errors.New("rerun already reserved for this run attempt; do not retry")

// This is the entire run-command allowlist: no aliases, inferred target,
// extra flags, full rerun, or cancellation can reach the write path.
func parseRerunFailed(args []string) (request, error) {
if len(args) != 5 || args[1] != "rerun-failed" || args[3] != repoFlag ||
!regexp.MustCompile(`^[1-9][0-9]*$`).MatchString(args[2]) ||
!regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`).MatchString(args[4]) {
return request{}, errUsage
}
if _, err := strconv.ParseUint(args[2], 10, 64); err != nil {
return request{}, errUsage
}
return request{operation: runRerunFailed, operand: args[2], repo: args[4]}, nil
}

type workflowRun struct {
ID uint64 `json:"id"`
Attempt uint64 `json:"run_attempt"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
URL string `json:"html_url"`
}

func (r *request) runURL() string {
return "https://github.com/" + strings.TrimPrefix(r.runEndpoint(), "repos/")
}

func (r *request) runEndpoint() string {
return "repos/" + r.repo + "/actions/runs/" + r.operand
}

func (r *request) readRun(ctx context.Context) (workflowRun, error) {
data, err := gh(ctx, []string{apiCommand, methodFlag, "GET", r.runEndpoint()})
var state workflowRun
if err != nil || json.Unmarshal(data, &state) != nil || state.Attempt == 0 || state.Status == "" ||
strconv.FormatUint(state.ID, 10) != r.operand || !strings.EqualFold(state.URL, r.runURL()) {
return workflowRun{}, errEnvironment
}
return state, nil
}

func printRunState(out io.Writer, state workflowRun) {
fmt.Fprintf(out, "status=%q conclusion=%q attempt=%d", state.Status, state.Conclusion, state.Attempt)
}

func (r *request) rerunFailed(
ctx context.Context, log *os.File, out, errOut io.Writer, syncFile func(*os.File) error,
) int {
state, err := r.readRun(ctx)
if err != nil {
fmt.Fprintln(errOut, "gh-post: cannot read exact workflow run")
return exitEnvironment
}
if state.Status != "completed" ||
(state.Conclusion != "failure" && state.Conclusion != "cancelled" && state.Conclusion != "timed_out") {
fmt.Fprint(errOut, "gh-post: run refused: ")
printRunState(errOut, state)
fmt.Fprintln(errOut)
return 1
}
if err = r.reserveRerun(filepath.Dir(log.Name()), state.Attempt, syncFile); err != nil {
if errors.Is(err, errRerunReserved) {
fmt.Fprintln(errOut, "gh-post:", err)
return 1
}
fmt.Fprintln(errOut, "gh-post: cannot reserve run attempt; no rerun requested")
return exitEnvironment
}
if err = r.auditRerun(log, state.Attempt); err != nil {
fmt.Fprintln(errOut, "gh-post: cannot audit reservation; no rerun requested; do not retry this attempt")
return exitEnvironment
}
_, err = gh(ctx, []string{apiCommand, methodFlag, "POST", r.runEndpoint() + "/rerun-failed-jobs"})
if err != nil {
// A transport failure can follow acceptance. Keep the reservation,
// read once for evidence, and never repeat the POST.
after, readErr := r.readRun(ctx)
fmt.Fprint(errOut, "gh-post: rerun outcome uncertain; do not retry this attempt; ")
if readErr != nil {
fmt.Fprintln(errOut, "cannot read run back")
} else {
printRunState(errOut, after)
fmt.Fprintln(errOut)
}
return exitEnvironment
}
fmt.Fprintln(out, r.runURL())
return 0
}

func (r *request) reserveRerun(dir string, attempt uint64, syncFile func(*os.File) error) error {
dir = filepath.Join(dir, "gh-post-reruns", strings.ToLower(r.repo), r.operand)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("reserve directory: %w", err)
}
// O_EXCL prevents concurrent processes from reserving the same attempt,
// including after an uncertain result. Keep receipts after write or sync failures.
file, err := os.OpenFile(
filepath.Join(dir, strconv.FormatUint(attempt, 10)),
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
0o600,
)
if errors.Is(err, os.ErrExist) {
return errRerunReserved
}
if err != nil {
return fmt.Errorf("reserve attempt: %w", err)
}
defer file.Close()
if _, err = file.WriteString("reserved\n"); err != nil {
return fmt.Errorf("write reservation: %w", err)
}
if err = syncFile(file); err != nil {
return fmt.Errorf("sync reservation: %w", err)
}
// Persist the receipt name and each directory entry created by MkdirAll,
// including ancestors created during audit setup, before any POST.
for {
if err = syncDir(dir, syncFile); err != nil {
return fmt.Errorf("sync reservation directory: %w", err)
}
if filepath.Dir(dir) == dir {
break
}
dir = filepath.Dir(dir)
}
return nil
}

func (r *request) auditRerun(log *os.File, attempt uint64) error {
record := struct {
Time string `json:"time"`
Operation string `json:"operation"`
URL string `json:"url"`
Attempt uint64 `json:"run_attempt"`
Outcome string `json:"outcome"`
}{time.Now().UTC().Format(time.RFC3339Nano), r.operation, r.runURL(), attempt, "reserved"}
if err := json.NewEncoder(log).Encode(record); err != nil {
return fmt.Errorf("audit reservation: %w", err)
}
if err := log.Sync(); err != nil {
return fmt.Errorf("sync audit: %w", err)
}
return nil
}
Loading
Loading