Skip to content

Commit 822af74

Browse files
fix(github_repository_file): produce signed delete commits via GraphQL
The Contents REST API does not trigger GitHub's server-side web-flow commit signing on DELETE the way it does on PUT, so deleting a github_repository_file produces an unsigned commit. When the target branch is protected by a ruleset that requires signed commits, the delete is rejected or lands as an unsigned commit on the protected branch. Route resourceGithubRepositoryFileDelete through the GraphQL createCommitOnBranch mutation with a single fileChanges.deletions entry. That mutation web-flow-signs every commit it produces, regardless of whether the change is an addition or a deletion, so delete commits are now signed identically to create/update commits made through the REST Contents API with unset commit_author/commit_email. The expected-head oid required by the mutation is fetched via a small GraphQL query against the target branch just before the mutation, so the caller does not have to supply it. Archived-repository errors are still handed to handleArchivedRepoDelete.
1 parent 7905a12 commit 822af74

2 files changed

Lines changed: 80 additions & 8 deletions

File tree

github/resource_github_repository_file.go

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/hashicorp/terraform-plugin-log/tflog"
1111
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1212
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
13+
"github.com/shurcooL/githubv4"
1314
)
1415

1516
func resourceGithubRepositoryFile() *schema.Resource {
@@ -403,23 +404,92 @@ func resourceGithubRepositoryFileUpdate(ctx context.Context, d *schema.ResourceD
403404
}
404405

405406
func resourceGithubRepositoryFileDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
406-
client := meta.(*Owner).v3client
407+
v4 := meta.(*Owner).v4client
407408
owner := meta.(*Owner).name
408409

409410
repo := d.Get("repository").(string)
410411
file := d.Get("file").(string)
412+
branch := d.Get("branch").(string)
411413

412-
opts := resourceGithubRepositoryFileOptions(d)
414+
ctx = tflog.SetField(ctx, "repository", repo)
415+
ctx = tflog.SetField(ctx, "file", file)
416+
ctx = tflog.SetField(ctx, "owner", owner)
417+
ctx = tflog.SetField(ctx, "branch", branch)
413418

414-
if *opts.Message == fmt.Sprintf("Add %s", file) {
415-
opts.Message = new(fmt.Sprintf("Delete %s", file))
419+
// The Contents REST endpoint DELETE /repos/{owner}/{repo}/contents/{path}
420+
// does not trigger GitHub's server-side web-flow commit signing, whereas
421+
// PUT on the same endpoint does. Using the GraphQL createCommitOnBranch
422+
// mutation produces a web-flow signed commit for deletions as well, which
423+
// is required by rulesets that enforce signed commits on protected
424+
// branches.
425+
headOid, err := getBranchHeadOid(ctx, v4, owner, repo, branch)
426+
if err != nil {
427+
return diag.FromErr(handleArchivedRepoDelete(err, "repository file", file, owner, repo))
416428
}
417429

418-
branch := d.Get("branch").(string)
419-
opts.Branch = new(branch)
430+
message := fmt.Sprintf("Delete %s", file)
431+
if cm, ok := d.GetOk("commit_message"); ok {
432+
if v := cm.(string); v != "" && v != fmt.Sprintf("Add %s", file) {
433+
message = v
434+
}
435+
}
420436

421-
_, _, err := client.Repositories.DeleteFile(ctx, owner, repo, file, opts)
422-
return diag.FromErr(handleArchivedRepoDelete(err, "repository file", file, owner, repo))
437+
nameWithOwner := githubv4.String(fmt.Sprintf("%s/%s", owner, repo))
438+
branchName := githubv4.String(branch)
439+
path := githubv4.String(file)
440+
441+
input := githubv4.CreateCommitOnBranchInput{
442+
Branch: githubv4.CommittableBranch{
443+
RepositoryNameWithOwner: &nameWithOwner,
444+
BranchName: &branchName,
445+
},
446+
Message: githubv4.CommitMessage{
447+
Headline: githubv4.String(message),
448+
},
449+
ExpectedHeadOid: githubv4.GitObjectID(headOid),
450+
FileChanges: &githubv4.FileChanges{
451+
Deletions: &[]githubv4.FileDeletion{{Path: path}},
452+
},
453+
}
454+
455+
var mutation struct {
456+
CreateCommitOnBranch struct {
457+
Commit struct {
458+
Oid githubv4.String
459+
}
460+
} `graphql:"createCommitOnBranch(input: $input)"`
461+
}
462+
463+
if err := v4.Mutate(ctx, &mutation, input, nil); err != nil {
464+
return diag.FromErr(handleArchivedRepoDelete(err, "repository file", file, owner, repo))
465+
}
466+
467+
return nil
468+
}
469+
470+
func getBranchHeadOid(ctx context.Context, client *githubv4.Client, owner, repo, branch string) (string, error) {
471+
var query struct {
472+
Repository struct {
473+
Ref struct {
474+
Target struct {
475+
Oid githubv4.String
476+
}
477+
} `graphql:"ref(qualifiedName: $ref)"`
478+
} `graphql:"repository(owner: $owner, name: $repo)"`
479+
}
480+
variables := map[string]any{
481+
"owner": githubv4.String(owner),
482+
"repo": githubv4.String(repo),
483+
"ref": githubv4.String("refs/heads/" + branch),
484+
}
485+
if err := client.Query(ctx, &query, variables); err != nil {
486+
return "", err
487+
}
488+
oid := string(query.Repository.Ref.Target.Oid)
489+
if oid == "" {
490+
return "", fmt.Errorf("could not resolve HEAD of branch %q on %s/%s", branch, owner, repo)
491+
}
492+
return oid, nil
423493
}
424494

425495
func autoBranchDiffSuppressFunc(k, _, _ string, d *schema.ResourceData) bool {

website/docs/r/repository_file.html.markdown

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ The following arguments are supported:
7878

7979
- `commit_message` - (Optional) The commit message when creating, updating or deleting the managed file.
8080

81+
~> **Note on signed commits:** delete commits are produced through GitHub's GraphQL `createCommitOnBranch` mutation so they are web-flow signed and satisfy rulesets that require signed commits. Create and update commits go through the REST Contents API, which is web-flow signed only when `commit_author` and `commit_email` are left unset.
82+
8183
- `overwrite_on_create` - (Optional) Enable overwriting existing files. If set to `true` it will overwrite an existing file with the same name. If set to `false` it will fail if there is an existing file with the same name.
8284

8385
- `autocreate_branch` - (Optional) **Deprecated** Automatically create the branch if it could not be found. Defaults to false. Subsequent reads if the branch is deleted will occur from 'autocreate_branch_source_branch'. Use the `github_branch` resource instead.

0 commit comments

Comments
 (0)