fix: org-governance release review findings + dev deploy targeting (#727, #733) #741

Merged
jmiller merged 1 commits from fix/org-governance-review into dev 2026-07-05 19:51:18 +00:00
4 changed files with 98 additions and 34 deletions
+9 -8
View File
@@ -96,15 +96,16 @@ jobs:
echo 'Restarting dev container...'
cd /opt/gitea-dev
sed -i "s|${{ env.IMAGE }}:[^ ]*|${{ env.IMAGE }}:$TAG|" docker-compose.yml
# The dev service uses a fixed container_name (mokogitea-dev). If a
# container with that name lingers under a different/none compose
# project (the symlinked /opt/gitea-dev path makes the derived project
# name unstable), `compose up` fails with a name conflict instead of
# recreating. Remove any such container first so the name is free, pin
# the project name for determinism, then force a fresh recreate.
# The dev service in the SHARED compose file reads its image tag from
# ${MOKOGITEA_DEV_TAG}. Drive it from the freshly built tag instead of
# rewriting the file with sed: the old sed pattern matched the *prod*
# service line (container_name: mokogitea) and left the dev service pinned
# to a stale image, so every dev deploy recreated old code while silently
# corrupting the prod image pin. The env-var only affects the dev service.
# Remove any lingering fixed-name container first so the recreate can't hit
# a name conflict, pin the project name for determinism, then force-recreate.
docker rm -f mokogitea-dev 2>/dev/null || true
docker compose -p gitea-dev up -d --force-recreate mokogitea-dev
MOKOGITEA_DEV_TAG="$TAG" docker compose -p gitea-dev up -d --force-recreate mokogitea-dev
echo 'Health check...'
for i in 1 2 3 4 5 6 7 8; do
+3 -1
View File
@@ -133,8 +133,10 @@ func getFirstMatchOrgProtectedBranchRule(ctx context.Context, repoID int64, bran
orgRule, err := FindOrgBranchRuleForBranch(ctx, owner.ID, branchName)
if err != nil {
// Fail closed: propagate the error so callers keep the org floor in force
// rather than silently falling back to the repo rule on a transient error.
log.Error("FindOrgBranchRuleForBranch: %v", err)
return nil, nil
return nil, err
}
if orgRule == nil {
return nil, nil
+12 -7
View File
@@ -26,27 +26,32 @@ func mergeMostRestrictive(repoRule, orgRule *ProtectedBranch) *ProtectedBranch {
eff.CanPush = repoRule.CanPush && orgRule.CanPush
eff.EnableWhitelist, eff.WhitelistUserIDs = mergeAllowlist(repoRule.EnableWhitelist, repoRule.WhitelistUserIDs, orgRule.EnableWhitelist, orgRule.WhitelistUserIDs)
_, eff.WhitelistTeamIDs = mergeAllowlist(repoRule.EnableWhitelist, repoRule.WhitelistTeamIDs, orgRule.EnableWhitelist, orgRule.WhitelistTeamIDs)
eff.WhitelistDeployKeys = repoRule.WhitelistDeployKeys && orgRule.WhitelistDeployKeys
eff.WhitelistActionsUser = repoRule.WhitelistActionsUser && orgRule.WhitelistActionsUser
// Deploy-key and Actions-user allowances are not expressible in an
// OrgProtectedBranch (it is team-only), so the org rule imposes no constraint on
// them; carry the repo values through unchanged. ANDing against the org side's
// always-false zero value would silently lock out every deploy-key and
// Actions-bot push in any org that has a matching branch rule (see #727 review).
eff.WhitelistDeployKeys = repoRule.WhitelistDeployKeys
eff.WhitelistActionsUser = repoRule.WhitelistActionsUser
// Force push.
eff.CanForcePush = repoRule.CanForcePush && orgRule.CanForcePush
eff.EnableForcePushAllowlist, eff.ForcePushAllowlistUserIDs = mergeAllowlist(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistUserIDs, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistUserIDs)
_, eff.ForcePushAllowlistTeamIDs = mergeAllowlist(repoRule.EnableForcePushAllowlist, repoRule.ForcePushAllowlistTeamIDs, orgRule.EnableForcePushAllowlist, orgRule.ForcePushAllowlistTeamIDs)
eff.ForcePushAllowlistDeployKeys = repoRule.ForcePushAllowlistDeployKeys && orgRule.ForcePushAllowlistDeployKeys
eff.ForcePushAllowlistActionsUser = repoRule.ForcePushAllowlistActionsUser && orgRule.ForcePushAllowlistActionsUser
eff.ForcePushAllowlistDeployKeys = repoRule.ForcePushAllowlistDeployKeys
eff.ForcePushAllowlistActionsUser = repoRule.ForcePushAllowlistActionsUser
// Delete.
eff.CanDelete = repoRule.CanDelete && orgRule.CanDelete
eff.EnableDeleteAllowlist, eff.DeleteAllowlistUserIDs = mergeAllowlist(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistUserIDs, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistUserIDs)
_, eff.DeleteAllowlistTeamIDs = mergeAllowlist(repoRule.EnableDeleteAllowlist, repoRule.DeleteAllowlistTeamIDs, orgRule.EnableDeleteAllowlist, orgRule.DeleteAllowlistTeamIDs)
eff.DeleteAllowlistDeployKeys = repoRule.DeleteAllowlistDeployKeys && orgRule.DeleteAllowlistDeployKeys
eff.DeleteAllowlistActionsUser = repoRule.DeleteAllowlistActionsUser && orgRule.DeleteAllowlistActionsUser
eff.DeleteAllowlistDeployKeys = repoRule.DeleteAllowlistDeployKeys
eff.DeleteAllowlistActionsUser = repoRule.DeleteAllowlistActionsUser
// Merge whitelist.
eff.EnableMergeWhitelist, eff.MergeWhitelistUserIDs = mergeAllowlist(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistUserIDs, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistUserIDs)
_, eff.MergeWhitelistTeamIDs = mergeAllowlist(repoRule.EnableMergeWhitelist, repoRule.MergeWhitelistTeamIDs, orgRule.EnableMergeWhitelist, orgRule.MergeWhitelistTeamIDs)
eff.MergeWhitelistActionsUser = repoRule.MergeWhitelistActionsUser && orgRule.MergeWhitelistActionsUser
eff.MergeWhitelistActionsUser = repoRule.MergeWhitelistActionsUser
// Status checks.
eff.EnableStatusCheck = repoRule.EnableStatusCheck || orgRule.EnableStatusCheck
+74 -18
View File
@@ -646,7 +646,7 @@ func (ctx *preReceiveContext) checkOrgPushPolicyBranch(oldCommitID, newCommitID,
}
if policy.MaxFileSize > 0 {
if path, size := ctx.largestBlobOverLimit(newCommitID, policy.MaxFileSize); path != "" {
if path, size := ctx.largestBlobOverLimit(oldCommitID, newCommitID, policy.MaxFileSize); path != "" {
ctx.JSON(http.StatusForbidden, private.Response{
UserMsg: fmt.Sprintf("Push rejected by the organization push policy: %q is %d bytes, over the %d-byte limit", path, size, policy.MaxFileSize),
})
@@ -673,34 +673,90 @@ func (ctx *preReceiveContext) checkOrgPushPolicyTag(tagName string) bool {
return true
}
// largestBlobOverLimit returns the first file (and its size) in the pushed tip tree
// that exceeds limit bytes, or ("", 0) if none — or on any error (fail open).
func (ctx *preReceiveContext) largestBlobOverLimit(commitID string, limit int64) (string, int64) {
output, _, err := gitrepo.RunCmdString(ctx,
ctx.Repo.Repository,
gitcmd.NewCommand("ls-tree", "-r", "--long").
AddDynamicArguments(commitID).
WithEnv(ctx.env),
)
if err != nil {
log.Error("org push policy ls-tree for %-v: %v", ctx.Repo.Repository, err)
// largestBlobOverLimit returns the first file (and its size) added or modified by the
// push (oldCommitID..newCommitID) whose blob exceeds limit bytes, or ("", 0) if none —
// or on any error (fail open). Only the pushed delta is inspected so a pre-existing
// oversized file committed before the policy existed cannot permanently block unrelated
// pushes. For a new branch (no old commit) the full new tree is scanned, since every
// blob is effectively introduced by the push.
func (ctx *preReceiveContext) largestBlobOverLimit(oldCommitID, newCommitID string, limit int64) (string, int64) {
emptyID := ctx.Repo.GetObjectFormat().EmptyObjectID().String()
// New branch: no base commit to diff against, so scan the full tip tree. ls-tree
// --long carries the blob size inline, so no extra sizing pass is needed.
if oldCommitID == "" || oldCommitID == emptyID {
output, _, err := gitrepo.RunCmdString(ctx, ctx.Repo.Repository,
gitcmd.NewCommand("ls-tree", "-r", "--long").AddDynamicArguments(newCommitID).WithEnv(ctx.env))
if err != nil {
log.Error("org push policy ls-tree for %-v: %v", ctx.Repo.Repository, err)
return "", 0
}
for _, line := range strings.Split(output, "\n") {
tab := strings.IndexByte(line, '\t')
if tab < 0 {
continue
}
fields := strings.Fields(line[:tab]) // mode, type, hash, size
if len(fields) < 4 || fields[1] != "blob" {
continue
}
if size, perr := strconv.ParseInt(fields[3], 10, 64); perr == nil && size > limit {
return line[tab+1:], size
}
}
return "", 0
}
// Existing branch: inspect only blobs added or modified by this push. diff-tree
// gives the changed paths and their new object ids but not sizes, so collect the
// candidate blob ids and size them in a single cat-file --batch-check pass.
output, _, err := gitrepo.RunCmdString(ctx, ctx.Repo.Repository,
gitcmd.NewCommand("diff-tree", "--no-commit-id", "-r").AddDynamicArguments(oldCommitID, newCommitID).WithEnv(ctx.env))
if err != nil {
log.Error("org push policy diff-tree for %-v: %v", ctx.Repo.Repository, err)
return "", 0
}
shaToPath := make(map[string]string)
var order []string
for _, line := range strings.Split(output, "\n") {
tab := strings.IndexByte(line, '\t')
if tab < 0 {
continue
}
fields := strings.Fields(line[:tab]) // mode, type, hash, size
if len(fields) < 4 || fields[1] != "blob" {
fields := strings.Fields(line[:tab]) // ":oldmode newmode oldsha newsha status"
if len(fields) < 5 {
continue
}
size, perr := strconv.ParseInt(fields[3], 10, 64)
if perr != nil {
newMode, newSha, status := fields[1], fields[3], fields[4]
// Only regular, executable, or symlink blobs; skip submodule gitlinks (160000).
if newMode != "100644" && newMode != "100755" && newMode != "120000" {
continue
}
if size > limit {
return line[tab+1:], size
if status == "D" || newSha == emptyID {
continue
}
if _, seen := shaToPath[newSha]; !seen {
order = append(order, newSha)
}
shaToPath[newSha] = line[tab+1:]
}
if len(order) == 0 {
return "", 0
}
output, _, err = gitrepo.RunCmdString(ctx, ctx.Repo.Repository,
gitcmd.NewCommand("cat-file", "--batch-check").WithStdinBytes([]byte(strings.Join(order, "\n")+"\n")).WithEnv(ctx.env))
if err != nil {
log.Error("org push policy cat-file for %-v: %v", ctx.Repo.Repository, err)
return "", 0
}
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line) // "<sha> <type> <size>" or "<sha> missing"
if len(fields) != 3 || fields[1] != "blob" {
continue
}
if size, perr := strconv.ParseInt(fields[2], 10, 64); perr == nil && size > limit {
return shaToPath[fields[0]], size
}
}
return "", 0