Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/guides/getting-started/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ To remove fullsend from a single repository:

**GitLab repos:**

1. Run `fullsend repos uninstall` to cleanly remove fullsend entries from `.gitlab-ci.yml` and delete `.gitlab/ci/fullsend-pipeline.yml` and `.fullsend/config.yaml`. If you prefer manual removal: delete `.gitlab/ci/fullsend-*.yml` and `.fullsend/config.yaml`, then edit `.gitlab-ci.yml` to remove the fullsend pipeline include entry, the fullsend workflow rules (`merge_request_event`, `schedule`, `api`), and the `auto_cancel` block if fullsend added it. Only delete `.gitlab-ci.yml` entirely if it contains no non-fullsend configuration.
1. Run `fullsend repos uninstall` to cleanly remove fullsend entries from `.gitlab-ci.yml` and delete `.gitlab/ci/fullsend-pipeline.yml` and `.fullsend/config.yaml`. If you prefer manual removal: delete `.gitlab/ci/fullsend-*.yml` and `.fullsend/config.yaml`, then edit `.gitlab-ci.yml` to remove the fullsend pipeline include entry, the fullsend stages (`dispatch`, `poll`, `agent`), the fullsend workflow rules (`merge_request_event`, `schedule`, `api`), and the `auto_cancel` block if fullsend added it. Only delete `.gitlab-ci.yml` entirely if it contains no non-fullsend configuration.

> **Note:** During install, fullsend sets `workflow.auto_cancel.on_new_commit: none` when no existing value is present but does not overwrite an existing value. Repos with `on_new_commit: interruptible` (or other non-`none` values) may experience agent pipeline cancellations because fullsend requires `on_new_commit: none` for reliable agent runs. If you see unexpected pipeline cancellations, set `on_new_commit: none` in your `.gitlab-ci.yml` workflow block.
> **Note:** During install, fullsend sets `workflow.auto_cancel.on_new_commit: none` when no existing value is present but does not overwrite an existing value. This only applies when the repo's `.gitlab-ci.yml` already contains a `workflow:` block — when no `workflow:` block exists, fullsend leaves it absent so push-triggered pipelines are not disrupted. Repos with `on_new_commit: interruptible` (or other non-`none` values) may experience agent pipeline cancellations because fullsend requires `on_new_commit: none` for reliable agent runs. If you see unexpected pipeline cancellations, set `on_new_commit: none` in your `.gitlab-ci.yml` workflow block.

2. Delete all CI/CD variables prefixed with `FULLSEND_`
3. Revoke the `fullsend-bot` project access token (Settings → Access Tokens)
Expand Down
119 changes: 88 additions & 31 deletions internal/repos/gitlabci.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ const fullsendPipelineInclude = ".gitlab/ci/fullsend-pipeline.yml"
// fullsend-specific workflow name.
const fullsendWorkflowNamePrefix = "fullsend "

// fullsendStages are the pipeline stages that fullsend's jobs use.
// GitLab's deep merge only applies to hash maps — stages: is a YAML
// array and is overwritten, not merged (see gitlab-org/gitlab#29980).
// When the root .gitlab-ci.yml already defines stages:, the included
// file's stages are silently dropped, so these must be added directly
// to the root file.
var fullsendStages = []string{"dispatch", "poll", "agent"}

// fullsendWorkflowRules are the workflow:rules entries that fullsend
// requires in the root .gitlab-ci.yml. GitLab does not merge workflow:
// definitions across includes, so these must be in the root file.
Expand All @@ -39,11 +47,17 @@ type workflowRule struct {
// .gitlab-ci.yml. It uses yaml.v3's Node API to preserve comments and
// formatting in the existing file.
//
// The merge performs two operations:
// The merge performs up to three operations:
// 1. Appends an include entry for .gitlab/ci/fullsend-pipeline.yml
// (skipped if already present)
// 2. Merges workflow:rules entries (deduplicates by if: condition),
// sets auto_cancel.on_new_commit: none if not present
// 2. Appends fullsend's stages (dispatch, poll, agent) to the
// existing stages: array, deduplicating if already present.
// Skipped when no stages: key exists.
// 3. When a workflow: block already exists, merges workflow:rules
// entries (deduplicates by if: condition) and sets
// auto_cancel.on_new_commit: none if not present. When no
// workflow: block exists, it is left absent so the repo's
// push-triggered pipelines are not disrupted.
//
// When existing is nil or empty, returns a minimal .gitlab-ci.yml with
// just the fullsend include and workflow block.
Expand All @@ -70,6 +84,7 @@ func MergeGitLabCI(existing []byte) ([]byte, error) {
if err := mergeInclude(root); err != nil {
return nil, err
}
mergeStages(root)
if err := mergeWorkflow(root); err != nil {
return nil, err
}
Expand Down Expand Up @@ -101,6 +116,7 @@ func UnmergeGitLabCI(existing []byte) ([]byte, error) {
}

removeInclude(root)
removeStages(root)
removeWorkflowRules(root)

// If the root mapping is empty after cleanup, the file has no
Expand Down Expand Up @@ -206,15 +222,46 @@ func isFullsendPipelineInclude(node *yaml.Node) bool {
return false
}

// mergeWorkflow ensures the workflow: block contains fullsend's rules
// and auto_cancel settings. Creates the block if it doesn't exist.
// mergeStages appends fullsend's stages to an existing stages: array,
// deduplicating entries that are already present. When no stages: key
// exists, the function is a no-op — the included pipeline file's
// stages will be used by GitLab automatically.
func mergeStages(root *yaml.Node) {
stagesVal := findMappingValue(root, "stages")
if stagesVal == nil || stagesVal.Kind != yaml.SequenceNode {
return
}

// Collect existing stage names for deduplication.
existing := make(map[string]bool)
for _, item := range stagesVal.Content {
if item.Kind == yaml.ScalarNode {
existing[item.Value] = true
}
}

// Append missing fullsend stages.
for _, stage := range fullsendStages {
if !existing[stage] {
stagesVal.Content = append(stagesVal.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: stage,
Tag: "!!str",
})
}
}
}

// mergeWorkflow ensures an existing workflow: block contains fullsend's
// rules and auto_cancel settings. When no workflow: block exists, it is
// left absent — fullsend's jobs have their own rules: that self-filter
// by pipeline source, and creating a workflow: block would gate all
// pipelines to only fullsend's sources, breaking push-triggered CI.
func mergeWorkflow(root *yaml.Node) error {
workflowVal := findMappingValue(root, "workflow")
if workflowVal == nil {
// Create the entire workflow: block.
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: "workflow", Tag: "!!str"}
valNode := buildWorkflowNode()
root.Content = append(root.Content, keyNode, valNode)
// No existing workflow block — leave it absent so the repo's
// push-triggered pipelines continue to run.
return nil
}

Expand Down Expand Up @@ -312,28 +359,6 @@ func makeRuleNode(r workflowRule) *yaml.Node {
}
}

// buildWorkflowNode creates a complete workflow: mapping node.
func buildWorkflowNode() *yaml.Node {
rulesSeq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, r := range fullsendWorkflowRules {
rulesSeq.Content = append(rulesSeq.Content, makeRuleNode(r))
}

return &yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "auto_cancel", Tag: "!!str"},
{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "on_new_commit", Tag: "!!str"},
{Kind: yaml.ScalarNode, Value: "none", Tag: "!!str"},
}},
{Kind: yaml.ScalarNode, Value: "rules", Tag: "!!str"},
rulesSeq,
},
}
}

// removeInclude removes the fullsend pipeline include entry from
// the include: sequence. If the sequence becomes empty, removes the
// include: key entirely.
Expand Down Expand Up @@ -362,6 +387,38 @@ func removeInclude(root *yaml.Node) {
}
}

// removeStages removes fullsend's stages from the stages: array. If
// the array becomes empty, removes the stages: key entirely.
func removeStages(root *yaml.Node) {
stagesIdx := findMappingKeyIndex(root, "stages")
if stagesIdx < 0 {
return
}
stagesVal := root.Content[stagesIdx+1]
if stagesVal.Kind != yaml.SequenceNode {
Comment thread
ggallen marked this conversation as resolved.
return
}

fsStages := make(map[string]bool)
for _, stage := range fullsendStages {
fsStages[stage] = true
}

var kept []*yaml.Node
for _, item := range stagesVal.Content {
if item.Kind == yaml.ScalarNode && fsStages[item.Value] {
continue
}
kept = append(kept, item)
}

if len(kept) == 0 {
root.Content = append(root.Content[:stagesIdx], root.Content[stagesIdx+2:]...)
} else {
stagesVal.Content = kept
}
}

// removeWorkflowRules removes fullsend's workflow:rules entries. If
// the workflow block has no remaining keys, removes it entirely.
func removeWorkflowRules(root *yaml.Node) {
Expand Down
149 changes: 144 additions & 5 deletions internal/repos/gitlabci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,16 @@ build:
assert.Contains(t, s, "- test")
assert.Contains(t, s, "make build")

// Fullsend entries added.
// Fullsend include added.
assert.Contains(t, s, "fullsend-pipeline.yml")
assert.Contains(t, s, "workflow:")
assert.Contains(t, s, "auto_cancel:")
assert.Contains(t, s, "on_new_commit: none")
assert.Contains(t, s, `$CI_PIPELINE_SOURCE == "merge_request_event"`)

// Fullsend stages appended to existing stages array.
assert.Contains(t, s, "- dispatch")
assert.Contains(t, s, "- poll")
assert.Contains(t, s, "- agent")

// No workflow block should be created when none existed.
assert.NotContains(t, s, "workflow:")
}

func TestMergeGitLabCI_ExistingWithWorkflowRules(t *testing.T) {
Expand Down Expand Up @@ -380,6 +384,136 @@ include:
assert.Contains(t, s, "fullsend-pipeline.yml")
}

func TestMergeGitLabCI_StagesAddedToExistingArray(t *testing.T) {
existing := []byte(`stages:
- build
- test
`)
result, err := MergeGitLabCI(existing)
require.NoError(t, err)
s := string(result)

// Original stages preserved.
assert.Contains(t, s, "- build")
assert.Contains(t, s, "- test")

// Fullsend stages appended.
assert.Contains(t, s, "- dispatch")
assert.Contains(t, s, "- poll")
assert.Contains(t, s, "- agent")
}

func TestMergeGitLabCI_StagesDeduplicatesExisting(t *testing.T) {
existing := []byte(`stages:
- build
- dispatch
- test
`)
result, err := MergeGitLabCI(existing)
require.NoError(t, err)
s := string(result)

// dispatch already present — should not be duplicated.
assert.Equal(t, 1, strings.Count(s, "- dispatch"), "dispatch stage should not be duplicated")

// Other fullsend stages added.
assert.Contains(t, s, "- poll")
assert.Contains(t, s, "- agent")
}

func TestMergeGitLabCI_NoWorkflowBlockCreatedWhenAbsent(t *testing.T) {
existing := []byte(`stages:
- build
job1:
script: echo hi
`)
result, err := MergeGitLabCI(existing)
require.NoError(t, err)
s := string(result)

// Fullsend include added.
assert.Contains(t, s, "fullsend-pipeline.yml")

// No workflow block should be created — fullsend's jobs
// self-filter via their own rules:.
assert.NotContains(t, s, "workflow:")
}

func TestMergeGitLabCI_NoStagesKeyLeftAlone(t *testing.T) {
// When no stages: key exists, fullsend's stages come from the
// included pipeline file and do not need to be in the root.
existing := []byte(`job1:
script: echo hi
`)
result, err := MergeGitLabCI(existing)
require.NoError(t, err)
s := string(result)

// No stages: key added.
assert.NotContains(t, s, "stages:")
}

func TestMergeGitLabCI_StagesIdempotent(t *testing.T) {
existing := []byte(`stages:
- build
- dispatch
- poll
- agent
`)
result, err := MergeGitLabCI(existing)
require.NoError(t, err)
s := string(result)

// All fullsend stages already present — no duplicates.
assert.Equal(t, 1, strings.Count(s, "- dispatch"))
assert.Equal(t, 1, strings.Count(s, "- poll"))
assert.Equal(t, 1, strings.Count(s, "- agent"))
}

func TestUnmergeGitLabCI_RemovesFullsendStages(t *testing.T) {
existing := []byte(`---
include:
- local: '.gitlab/ci/fullsend-pipeline.yml'

stages:
- build
- test
- dispatch
- poll
- agent
`)
result, err := UnmergeGitLabCI(existing)
require.NoError(t, err)
require.NotNil(t, result)
s := string(result)

// Fullsend stages removed.
assert.NotContains(t, s, "- dispatch")
assert.NotContains(t, s, "- poll")
assert.NotContains(t, s, "- agent")

// User stages preserved.
assert.Contains(t, s, "- build")
assert.Contains(t, s, "- test")
}

func TestUnmergeGitLabCI_RemovesStagesKeyWhenEmpty(t *testing.T) {
existing := []byte(`---
include:
- local: '.gitlab/ci/fullsend-pipeline.yml'

stages:
- dispatch
- poll
- agent
`)
result, err := UnmergeGitLabCI(existing)
require.NoError(t, err)

// Everything was fullsend-only, file should be nil.
assert.Nil(t, result, "file should be nil when only fullsend content remains")
}

func TestMergeGitLabCI_PreservesComments(t *testing.T) {
existing := []byte(`---
# My project CI configuration
Expand All @@ -400,4 +534,9 @@ build:
// Comments should be preserved by yaml.Node API.
assert.Contains(t, s, "# My project CI configuration")
assert.Contains(t, s, "# Build job")

// Fullsend stages added to existing array.
assert.Contains(t, s, "- dispatch")
assert.Contains(t, s, "- poll")
assert.Contains(t, s, "- agent")
}
Loading