Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-02-28 09:09:45 +00:00
parent cddb9394be
commit a9abb02902
47 changed files with 952 additions and 296 deletions

View File

@ -3316,7 +3316,6 @@ RSpec/FeatureCategory:
- 'spec/models/legacy_diff_discussion_spec.rb' - 'spec/models/legacy_diff_discussion_spec.rb'
- 'spec/models/legacy_diff_note_spec.rb' - 'spec/models/legacy_diff_note_spec.rb'
- 'spec/models/lfs_download_object_spec.rb' - 'spec/models/lfs_download_object_spec.rb'
- 'spec/models/lfs_file_lock_spec.rb'
- 'spec/models/license_template_spec.rb' - 'spec/models/license_template_spec.rb'
- 'spec/models/list_spec.rb' - 'spec/models/list_spec.rb'
- 'spec/models/list_user_preference_spec.rb' - 'spec/models/list_user_preference_spec.rb'

View File

@ -1 +1 @@
0.0.24 0.0.26

View File

@ -147,6 +147,7 @@ export default {
form: {}, form: {},
errorTitle: null, errorTitle: null,
error: null, error: null,
pipelineVariables: [],
predefinedVariables: null, predefinedVariables: null,
warnings: [], warnings: [],
totalWarnings: 0, totalWarnings: 0,
@ -277,6 +278,9 @@ export default {
clearTimeout(pollTimeout); clearTimeout(pollTimeout);
this.$apollo.queries.ciConfigVariables.stopPolling(); this.$apollo.queries.ciConfigVariables.stopPolling();
}, },
handleVariablesUpdated(updatedVariables) {
this.pipelineVariables = updatedVariables;
},
populateForm() { populateForm() {
this.configVariablesWithDescription = this.predefinedVariables.reduce( this.configVariablesWithDescription = this.predefinedVariables.reduce(
(accumulator, { description, key, value, valueOptions }) => { (accumulator, { description, key, value, valueOptions }) => {
@ -367,7 +371,9 @@ export default {
input: { input: {
projectPath: this.projectPath, projectPath: this.projectPath,
ref: this.refShortName, ref: this.refShortName,
variables: filterVariables(this.variables), variables: this.isUsingPipelineInputs
? this.pipelineVariables
: filterVariables(this.variables),
}, },
}, },
}); });
@ -488,8 +494,13 @@ export default {
<pipeline-variables-form <pipeline-variables-form
v-if="isUsingPipelineInputs" v-if="isUsingPipelineInputs"
:default-branch="defaultBranch" :default-branch="defaultBranch"
:file-params="fileParams"
:is-maintainer="isMaintainer"
:project-path="projectPath" :project-path="projectPath"
:ref-param="refParam" :ref-param="refParam"
:settings-link="settingsLink"
:variable-params="variableParams"
@variables-updated="handleVariablesUpdated"
/> />
<div v-else> <div v-else>
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="md" /> <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="md" />

View File

@ -1,16 +1,68 @@
<script> <script>
import { GlLoadingIcon, GlFormGroup } from '@gitlab/ui'; import {
import { fetchPolicies } from '~/lib/graphql'; GlIcon,
GlButton,
GlCollapsibleListbox,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlLink,
GlLoadingIcon,
GlSprintf,
} from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import { reportToSentry } from '~/ci/utils'; import { reportToSentry } from '~/ci/utils';
import { fetchPolicies } from '~/lib/graphql';
import filterVariables from '../utils/filter_variables';
import {
CONFIG_VARIABLES_TIMEOUT,
CI_VARIABLE_TYPE_FILE,
CI_VARIABLE_TYPE_ENV_VAR,
} from '../constants';
import ciConfigVariablesQuery from '../graphql/queries/ci_config_variables.graphql'; import ciConfigVariablesQuery from '../graphql/queries/ci_config_variables.graphql';
import VariableValuesListbox from './variable_values_listbox.vue';
let pollTimeout;
export const POLLING_INTERVAL = 2000;
export default { export default {
name: 'PipelineVariablesForm', name: 'PipelineVariablesForm',
formElementClasses: 'gl-basis-1/4 gl-shrink-0 gl-flex-grow-0',
learnMorePath: helpPagePath('ci/variables/_index', {
anchor: 'cicd-variable-precedence',
}),
// this height value is used inline on the textarea to match the input field height
// it's used to prevent the overwrite if 'gl-h-7' or '!gl-h-7' were used
textAreaStyle: { height: '32px' },
components: { components: {
GlLoadingIcon, GlIcon,
GlButton,
GlCollapsibleListbox,
GlFormGroup, GlFormGroup,
GlFormInput,
GlFormTextarea,
GlLink,
GlLoadingIcon,
GlSprintf,
VariableValuesListbox,
}, },
props: { props: {
defaultBranch: {
type: String,
required: true,
},
fileParams: {
type: Object,
required: false,
default: () => ({}),
},
isMaintainer: {
type: Boolean,
required: true,
},
projectPath: { projectPath: {
type: String, type: String,
required: true, required: true,
@ -19,16 +71,26 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
defaultBranch: { settingsLink: {
type: String, type: String,
required: true, required: true,
}, },
variableParams: {
type: Object,
required: false,
default: () => ({}),
},
}, },
data() { data() {
return { return {
ciConfigVariables: null, ciConfigVariables: null,
configVariablesWithDescription: {},
form: {},
refValue: { refValue: {
shortName: this.refParam, shortName: this.refParam,
// this is needed until we add support for ref type in url query strings
// ensure default branch is called with full ref on load
// https://gitlab.com/gitlab-org/gitlab/-/issues/287815
fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined,
}, },
}; };
@ -37,6 +99,9 @@ export default {
ciConfigVariables: { ciConfigVariables: {
fetchPolicy: fetchPolicies.NO_CACHE, fetchPolicy: fetchPolicies.NO_CACHE,
query: ciConfigVariablesQuery, query: ciConfigVariablesQuery,
skip() {
return Object.keys(this.form).includes(this.refFullName);
},
variables() { variables() {
return { return {
fullPath: this.projectPath, fullPath: this.projectPath,
@ -46,21 +111,183 @@ export default {
update({ project }) { update({ project }) {
return project?.ciConfigVariables || []; return project?.ciConfigVariables || [];
}, },
result() {
// API cache is empty when ciConfigVariables === null, so we need to
// poll while cache values are being populated in the backend.
// After CONFIG_VARIABLES_TIMEOUT ms have passed, we stop polling
// and populate the form regardless.
if (this.isFetchingCiConfigVariables && !pollTimeout) {
pollTimeout = setTimeout(() => {
this.ciConfigVariables = [];
this.clearPolling();
this.populateForm();
}, CONFIG_VARIABLES_TIMEOUT);
}
if (!this.isFetchingCiConfigVariables) {
this.clearPolling();
this.populateForm();
}
},
error(error) { error(error) {
reportToSentry(this.$options.name, error); reportToSentry(this.$options.name, error);
}, },
pollInterval: POLLING_INTERVAL,
}, },
}, },
computed: { computed: {
descriptions() {
return this.form[this.refFullName]?.descriptions ?? {};
},
isFetchingCiConfigVariables() { isFetchingCiConfigVariables() {
return this.ciConfigVariables === null; return this.ciConfigVariables === null;
}, },
isLoading() { isLoading() {
return this.$apollo.queries.ciConfigVariables.loading || this.isFetchingCiConfigVariables; return this.$apollo.queries.ciConfigVariables.loading || this.isFetchingCiConfigVariables;
}, },
isMobile() {
return ['sm', 'xs'].includes(GlBreakpointInstance.getBreakpointSize());
},
refFullName() {
return this.refValue.fullName;
},
refQueryParam() { refQueryParam() {
return this.refValue.fullName || this.refValue.shortName; return this.refFullName || this.refShortName;
},
refShortName() {
return this.refValue.shortName;
},
removeButtonCategory() {
return this.isMobile ? 'secondary' : 'tertiary';
},
variables() {
return this.form[this.refFullName]?.variables ?? [];
},
variableTypeListboxItems() {
return [
{
value: CI_VARIABLE_TYPE_ENV_VAR,
text: s__('Pipeline|Variable'),
},
{
value: CI_VARIABLE_TYPE_FILE,
text: s__('Pipeline|File'),
},
];
},
},
watch: {
variables: {
handler(newVariables) {
this.$emit('variables-updated', filterVariables(newVariables));
},
deep: true,
},
},
methods: {
addEmptyVariable(refValue) {
const { variables } = this.form[refValue];
const lastVar = variables[variables.length - 1];
if (lastVar?.key === '' && lastVar?.value === '') {
return;
}
variables.push({
uniqueId: uniqueId(`var-${refValue}`),
variableType: CI_VARIABLE_TYPE_ENV_VAR,
key: '',
value: '',
});
},
canRemove(index) {
return index < this.variables.length - 1;
},
clearPolling() {
clearTimeout(pollTimeout);
this.$apollo.queries.ciConfigVariables.stopPolling();
},
createListItemsFromVariableOptions(key) {
return this.configVariablesWithDescription.options[key].map((option) => ({
text: option,
value: option,
}));
},
getPipelineAriaLabel(index) {
return `${s__('Pipeline|Variable')} ${index + 1}`;
},
populateForm() {
this.configVariablesWithDescription = this.ciConfigVariables.reduce(
(accumulator, { description, key, value, valueOptions }) => {
if (description) {
accumulator.descriptions[key] = description;
accumulator.values[key] = value;
accumulator.options[key] = valueOptions;
}
return accumulator;
},
{ descriptions: {}, values: {}, options: {} },
);
this.form = {
...this.form,
[this.refFullName]: {
descriptions: this.configVariablesWithDescription.descriptions,
variables: [],
},
};
// Add default variables from yml
this.setVariableParams(
this.refFullName,
CI_VARIABLE_TYPE_ENV_VAR,
this.configVariablesWithDescription.values,
);
// Add/update variables, e.g. from query string
if (this.variableParams) {
this.setVariableParams(this.refFullName, CI_VARIABLE_TYPE_ENV_VAR, this.variableParams);
}
if (this.fileParams) {
this.setVariableParams(this.refFullName, CI_VARIABLE_TYPE_FILE, this.fileParams);
}
// Adds empty var at the end of the form
this.addEmptyVariable(this.refFullName);
},
removeVariable(index) {
this.variables.splice(index, 1);
},
setVariableAttribute(key, attribute, value) {
const { variables } = this.form[this.refFullName];
const variable = variables.find((v) => v.key === key);
variable[attribute] = value;
},
setVariable(refValue, { type, key, value }) {
const { variables } = this.form[refValue];
const variable = variables.find((v) => v.key === key);
if (variable) {
variable.variableType = type;
variable.value = value;
} else {
variables.push({
uniqueId: uniqueId(`var-${refValue}`),
key,
value,
variableType: type,
});
}
},
setVariableParams(refValue, type, paramsObj) {
Object.entries(paramsObj).forEach(([key, value]) => {
this.setVariable(refValue, { type, key, value });
});
},
shouldShowValuesDropdown(key) {
return this.configVariablesWithDescription.options[key]?.length > 1;
}, },
}, },
}; };
@ -69,8 +296,105 @@ export default {
<template> <template>
<div> <div>
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="md" /> <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="md" />
<gl-form-group v-else> <gl-form-group v-else :label="s__('Pipeline|Variables')">
<pre>{{ ciConfigVariables }}</pre> <div
v-for="(variable, index) in variables"
:key="variable.uniqueId"
class="gl-mb-4"
data-testid="ci-variable-row-container"
>
<div class="gl-flex gl-flex-col gl-items-stretch gl-gap-4 md:gl-flex-row">
<gl-collapsible-listbox
:items="variableTypeListboxItems"
:selected="variable.variableType"
block
fluid-width
:aria-label="getPipelineAriaLabel(index)"
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-type"
@select="setVariableAttribute(variable.key, 'variableType', $event)"
/>
<gl-form-input
v-model="variable.key"
:placeholder="s__('CiVariables|Input variable key')"
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-key-field"
@change="addEmptyVariable(refFullName)"
/>
<variable-values-listbox
v-if="shouldShowValuesDropdown(variable.key)"
:items="createListItemsFromVariableOptions(variable.key)"
:selected="variable.value"
:class="$options.formElementClasses"
class="!gl-mr-0 gl-grow"
data-testid="pipeline-form-ci-variable-value-dropdown"
@select="setVariableAttribute(variable.key, 'value', $event)"
/>
<gl-form-textarea
v-else
v-model="variable.value"
:placeholder="s__('CiVariables|Input variable value')"
:style="$options.textAreaStyle"
:no-resize="false"
data-testid="pipeline-form-ci-variable-value-field"
/>
<template v-if="variables.length > 1">
<gl-button
v-if="canRemove(index)"
size="small"
class="gl-shrink-0"
data-testid="remove-ci-variable-row"
:category="removeButtonCategory"
:aria-label="s__('CiVariables|Remove variable')"
@click="removeVariable(index)"
>
<gl-icon class="!gl-mr-0" name="remove" />
<span class="md:gl-hidden">{{ s__('CiVariables|Remove variable') }}</span>
</gl-button>
<gl-button
v-else
class="gl-invisible gl-hidden gl-shrink-0 md:gl-block"
icon="remove"
:aria-label="s__('CiVariables|Remove variable')"
/>
</template>
</div>
<div v-if="descriptions[variable.key]" class="gl-text-subtle">
{{ descriptions[variable.key] }}
</div>
</div>
<template #description>
<gl-sprintf
:message="
s__(
'Pipeline|Specify variable values to be used in this run. The variables specified in the configuration file as well as %{linkStart}CI/CD settings%{linkEnd} are used by default.',
)
"
>
<template #link="{ content }">
<gl-link v-if="isMaintainer" :href="settingsLink" data-testid="ci-cd-settings-link">
{{ content }}
</gl-link>
<template v-else>{{ content }}</template>
</template>
</gl-sprintf>
<gl-link :href="$options.learnMorePath" target="_blank">
{{ __('Learn more') }}
</gl-link>
<div class="gl-mt-4 gl-text-subtle">
<gl-sprintf
:message="
s__(
'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}',
)
"
>
<template #bold="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</div>
</template>
</gl-form-group> </gl-form-group>
</div> </div>
</template> </template>

View File

@ -12,6 +12,11 @@ export default {
event: 'change', event: 'change',
}, },
props: { props: {
id: {
type: String,
required: false,
default: '',
},
label: { label: {
type: String, type: String,
required: false, required: false,
@ -134,6 +139,7 @@ export default {
/> />
<div class="select-wrapper gl-grow"> <div class="select-wrapper gl-grow">
<select <select
:id="id"
v-model="internalValue" v-model="internalValue"
:disabled="disableSelectInput" :disabled="disableSelectInput"
class="form-control project-repo-select select-control" class="form-control project-repo-select select-control"

View File

@ -1,11 +1,21 @@
<script> <script>
import { GlFormGroup } from '@gitlab/ui';
export default { export default {
components: {
GlFormGroup,
},
props: { props: {
label: { label: {
type: String, type: String,
required: false, required: false,
default: null, default: null,
}, },
labelFor: {
type: String,
required: false,
default: null,
},
helpPath: { helpPath: {
type: String, type: String,
required: false, required: false,
@ -26,20 +36,27 @@ export default {
</script> </script>
<template> <template>
<div class="project-feature-row"> <gl-form-group :label-for="labelFor" label-class="!gl-pb-1" class="project-feature-row gl-mb-0">
<div class="gl-flex"> <template #label>
<label v-if="label" class="label-bold !gl-mb-0" :class="{ 'gl-text-disabled': locked }"> <span
v-if="label"
:class="{ 'gl-text-disabled': locked }"
data-testid="project-settings-row-label"
>
{{ label }} {{ label }}
</label> </span>
<slot name="label-icon"></slot> <slot name="label-icon"></slot>
</div> </template>
<div> <div>
<span v-if="helpText" class="gl-text-subtle"> {{ helpText }} </span> <span v-if="helpText" class="gl-text-subtle" data-testid="project-settings-row-help-text">
{{ helpText }}
</span>
<span v-if="helpPath" <span v-if="helpPath"
><a :href="helpPath" target="_blank">{{ __('Learn more') }}</a ><a :href="helpPath" target="_blank">{{ __('Learn more') }}</a
>.</span >.</span
> >
</div> </div>
<slot></slot> <slot></slot>
</div> </gl-form-group>
</template> </template>

View File

@ -573,12 +573,14 @@ export default {
ref="project-visibility-settings" ref="project-visibility-settings"
:help-path="visibilityHelpPath" :help-path="visibilityHelpPath"
:label="s__('ProjectSettings|Project visibility')" :label="s__('ProjectSettings|Project visibility')"
label-for="project_visibility_level"
:help-text=" :help-text="
s__('ProjectSettings|Manage who can see the project in the public access directory.') s__('ProjectSettings|Manage who can see the project in the public access directory.')
" "
> >
<div class="project-feature-controls gl-mx-0 gl-my-3 gl-flex gl-items-center"> <div class="project-feature-controls gl-mx-0 gl-my-3 gl-flex gl-items-center">
<gl-form-select <gl-form-select
id="project_visibility_level"
v-model="visibilityLevel" v-model="visibilityLevel"
:disabled="!canChangeVisibilityLevel" :disabled="!canChangeVisibilityLevel"
name="project[visibility_level]" name="project[visibility_level]"
@ -654,6 +656,7 @@ export default {
ref="issues-settings" ref="issues-settings"
:help-path="issuesHelpPath" :help-path="issuesHelpPath"
:label="$options.i18n.issuesLabel" :label="$options.i18n.issuesLabel"
label-for="issues_access_level"
:help-text=" :help-text="
s__( s__(
'ProjectSettings|Flexible tool to collaboratively develop ideas and plan work in this project.', 'ProjectSettings|Flexible tool to collaboratively develop ideas and plan work in this project.',
@ -661,6 +664,7 @@ export default {
" "
> >
<project-feature-setting <project-feature-setting
id="issues_access_level"
v-model="issuesAccessLevel" v-model="issuesAccessLevel"
:label="$options.i18n.issuesLabel" :label="$options.i18n.issuesLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -687,9 +691,11 @@ export default {
<project-setting-row <project-setting-row
ref="repository-settings" ref="repository-settings"
:label="$options.i18n.repositoryLabel" :label="$options.i18n.repositoryLabel"
label-for="repository_access_level"
:help-text="repositoryHelpText" :help-text="repositoryHelpText"
> >
<project-feature-setting <project-feature-setting
id="repository_access_level"
v-model="repositoryAccessLevel" v-model="repositoryAccessLevel"
:label="$options.i18n.repositoryLabel" :label="$options.i18n.repositoryLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -701,9 +707,11 @@ export default {
<project-setting-row <project-setting-row
ref="merge-request-settings" ref="merge-request-settings"
:label="$options.i18n.mergeRequestsLabel" :label="$options.i18n.mergeRequestsLabel"
label-for="merge_requests_access_level"
:help-text="s__('ProjectSettings|Submit changes to be merged upstream.')" :help-text="s__('ProjectSettings|Submit changes to be merged upstream.')"
> >
<project-feature-setting <project-feature-setting
id="merge_requests_access_level"
v-model="mergeRequestsAccessLevel" v-model="mergeRequestsAccessLevel"
:label="$options.i18n.mergeRequestsLabel" :label="$options.i18n.mergeRequestsLabel"
:options="repoFeatureAccessLevelOptions" :options="repoFeatureAccessLevelOptions"
@ -715,9 +723,11 @@ export default {
<project-setting-row <project-setting-row
ref="fork-settings" ref="fork-settings"
:label="$options.i18n.forksLabel" :label="$options.i18n.forksLabel"
label-for="forking_access_level"
:help-text="s__('ProjectSettings|Users can copy the repository to a new project.')" :help-text="s__('ProjectSettings|Users can copy the repository to a new project.')"
> >
<project-feature-setting <project-feature-setting
id="forking_access_level"
v-model="forkingAccessLevel" v-model="forkingAccessLevel"
:label="$options.i18n.forksLabel" :label="$options.i18n.forksLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -764,6 +774,7 @@ export default {
<project-setting-row <project-setting-row
ref="pipeline-settings" ref="pipeline-settings"
:label="$options.i18n.ciCdLabel" :label="$options.i18n.ciCdLabel"
label-for="builds_access_level"
:help-text=" :help-text="
s__( s__(
'ProjectSettings|Build, test, and deploy your changes. Does not apply to project integrations.', 'ProjectSettings|Build, test, and deploy your changes. Does not apply to project integrations.',
@ -771,6 +782,7 @@ export default {
" "
> >
<project-feature-setting <project-feature-setting
id="builds_access_level"
v-model="buildsAccessLevel" v-model="buildsAccessLevel"
:label="$options.i18n.ciCdLabel" :label="$options.i18n.ciCdLabel"
:options="repoFeatureAccessLevelOptions" :options="repoFeatureAccessLevelOptions"
@ -785,6 +797,7 @@ export default {
ref="container-registry-settings" ref="container-registry-settings"
:help-path="registryHelpPath" :help-path="registryHelpPath"
:label="$options.i18n.containerRegistryLabel" :label="$options.i18n.containerRegistryLabel"
label-for="container_registry_access_level"
:help-text=" :help-text="
s__('ProjectSettings|Every project can have its own space to store its Docker images') s__('ProjectSettings|Every project can have its own space to store its Docker images')
" "
@ -803,6 +816,7 @@ export default {
</gl-sprintf> </gl-sprintf>
</div> </div>
<project-feature-setting <project-feature-setting
id="container_registry_access_level"
v-model="containerRegistryAccessLevel" v-model="containerRegistryAccessLevel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
:disabled-select-input="isProjectPrivate" :disabled-select-input="isProjectPrivate"
@ -813,9 +827,11 @@ export default {
<project-setting-row <project-setting-row
ref="analytics-settings" ref="analytics-settings"
:label="$options.i18n.analyticsLabel" :label="$options.i18n.analyticsLabel"
label-for="analytics_access_level"
:help-text="s__('ProjectSettings|View project analytics.')" :help-text="s__('ProjectSettings|View project analytics.')"
> >
<project-feature-setting <project-feature-setting
id="analytics_access_level"
v-model="analyticsAccessLevel" v-model="analyticsAccessLevel"
:label="$options.i18n.analyticsLabel" :label="$options.i18n.analyticsLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -827,9 +843,11 @@ export default {
v-if="requirementsAvailable" v-if="requirementsAvailable"
ref="requirements-settings" ref="requirements-settings"
:label="$options.i18n.requirementsLabel" :label="$options.i18n.requirementsLabel"
label-for="requirements_access_level"
:help-text="s__('ProjectSettings|Requirements management system.')" :help-text="s__('ProjectSettings|Requirements management system.')"
> >
<project-feature-setting <project-feature-setting
id="requirements_access_level"
v-model="requirementsAccessLevel" v-model="requirementsAccessLevel"
:label="$options.i18n.requirementsLabel" :label="$options.i18n.requirementsLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -839,9 +857,11 @@ export default {
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
:label="$options.i18n.securityAndComplianceLabel" :label="$options.i18n.securityAndComplianceLabel"
label-for="security_and_compliance_access_level"
:help-text="s__('ProjectSettings|Security and compliance for this project.')" :help-text="s__('ProjectSettings|Security and compliance for this project.')"
> >
<project-feature-setting <project-feature-setting
id="security_and_compliance_access_level"
v-model="securityAndComplianceAccessLevel" v-model="securityAndComplianceAccessLevel"
:label="$options.i18n.securityAndComplianceLabel" :label="$options.i18n.securityAndComplianceLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -852,9 +872,11 @@ export default {
<project-setting-row <project-setting-row
ref="wiki-settings" ref="wiki-settings"
:label="$options.i18n.wikiLabel" :label="$options.i18n.wikiLabel"
label-for="wiki_access_level"
:help-text="s__('ProjectSettings|Pages for project documentation.')" :help-text="s__('ProjectSettings|Pages for project documentation.')"
> >
<project-feature-setting <project-feature-setting
id="wiki_access_level"
v-model="wikiAccessLevel" v-model="wikiAccessLevel"
:label="$options.i18n.wikiLabel" :label="$options.i18n.wikiLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -865,9 +887,11 @@ export default {
<project-setting-row <project-setting-row
ref="snippet-settings" ref="snippet-settings"
:label="$options.i18n.snippetsLabel" :label="$options.i18n.snippetsLabel"
label-for="snippets_access_level"
:help-text="s__('ProjectSettings|Share code with others outside the project.')" :help-text="s__('ProjectSettings|Share code with others outside the project.')"
> >
<project-feature-setting <project-feature-setting
id="snippets_access_level"
v-model="snippetsAccessLevel" v-model="snippetsAccessLevel"
:label="$options.i18n.snippetsLabel" :label="$options.i18n.snippetsLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -918,10 +942,12 @@ export default {
<project-setting-row <project-setting-row
ref="model-experiments-settings" ref="model-experiments-settings"
:label="$options.i18n.modelExperimentsLabel" :label="$options.i18n.modelExperimentsLabel"
label-for="model_experiments_access_level"
:help-text="$options.i18n.modelExperimentsHelpText" :help-text="$options.i18n.modelExperimentsHelpText"
:help-path="$options.modelExperimentsHelpPath" :help-path="$options.modelExperimentsHelpPath"
> >
<project-feature-setting <project-feature-setting
id="model_experiments_access_level"
v-model="modelExperimentsAccessLevel" v-model="modelExperimentsAccessLevel"
:label="$options.i18n.modelExperimentsLabel" :label="$options.i18n.modelExperimentsLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -932,10 +958,12 @@ export default {
<project-setting-row <project-setting-row
ref="model-registry-settings" ref="model-registry-settings"
:label="$options.i18n.modelRegistryLabel" :label="$options.i18n.modelRegistryLabel"
label-for="model_registry_access_level"
:help-text="$options.i18n.modelRegistryHelpText" :help-text="$options.i18n.modelRegistryHelpText"
:help-path="$options.modelRegistryHelpPath" :help-path="$options.modelRegistryHelpPath"
> >
<project-feature-setting <project-feature-setting
id="model_registry_access_level"
v-model="modelRegistryAccessLevel" v-model="modelRegistryAccessLevel"
:label="$options.i18n.modelRegistryLabel" :label="$options.i18n.modelRegistryLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -948,6 +976,7 @@ export default {
ref="pages-settings" ref="pages-settings"
:help-path="pagesHelpPath" :help-path="pagesHelpPath"
:label="$options.i18n.pagesLabel" :label="$options.i18n.pagesLabel"
label-for="pages_access_level"
:help-text=" :help-text="
s__( s__(
'ProjectSettings|With GitLab Pages you can host your static websites on GitLab. GitLab Pages uses a caching mechanism for efficiency. Your changes may not take effect until that cache is invalidated, which usually takes less than a minute.', 'ProjectSettings|With GitLab Pages you can host your static websites on GitLab. GitLab Pages uses a caching mechanism for efficiency. Your changes may not take effect until that cache is invalidated, which usually takes less than a minute.',
@ -955,6 +984,7 @@ export default {
" "
> >
<project-feature-setting <project-feature-setting
id="pages_access_level"
v-model="pagesAccessLevel" v-model="pagesAccessLevel"
:label="$options.i18n.pagesLabel" :label="$options.i18n.pagesLabel"
:access-control-forced="pagesAccessControlForced" :access-control-forced="pagesAccessControlForced"
@ -965,11 +995,13 @@ export default {
<project-setting-row <project-setting-row
ref="monitor-settings" ref="monitor-settings"
:label="$options.i18n.monitorLabel" :label="$options.i18n.monitorLabel"
label-for="monitor_access_level"
:help-text=" :help-text="
s__('ProjectSettings|Monitor the health of your project and respond to incidents.') s__('ProjectSettings|Monitor the health of your project and respond to incidents.')
" "
> >
<project-feature-setting <project-feature-setting
id="monitor_access_level"
v-model="monitorAccessLevel" v-model="monitorAccessLevel"
:label="$options.i18n.monitorLabel" :label="$options.i18n.monitorLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -980,10 +1012,12 @@ export default {
<project-setting-row <project-setting-row
ref="environments-settings" ref="environments-settings"
:label="$options.i18n.environmentsLabel" :label="$options.i18n.environmentsLabel"
label-for="environments_access_level"
:help-text="$options.i18n.environmentsHelpText" :help-text="$options.i18n.environmentsHelpText"
:help-path="environmentsHelpPath" :help-path="environmentsHelpPath"
> >
<project-feature-setting <project-feature-setting
id="environments_access_level"
v-model="environmentsAccessLevel" v-model="environmentsAccessLevel"
:label="$options.i18n.environmentsLabel" :label="$options.i18n.environmentsLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -994,10 +1028,12 @@ export default {
<project-setting-row <project-setting-row
ref="feature-flags-settings" ref="feature-flags-settings"
:label="$options.i18n.featureFlagsLabel" :label="$options.i18n.featureFlagsLabel"
label-for="feature_flags_access_level"
:help-text="$options.i18n.featureFlagsHelpText" :help-text="$options.i18n.featureFlagsHelpText"
:help-path="featureFlagsHelpPath" :help-path="featureFlagsHelpPath"
> >
<project-feature-setting <project-feature-setting
id="feature_flags_access_level"
v-model="featureFlagsAccessLevel" v-model="featureFlagsAccessLevel"
:label="$options.i18n.featureFlagsLabel" :label="$options.i18n.featureFlagsLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -1008,10 +1044,12 @@ export default {
<project-setting-row <project-setting-row
ref="infrastructure-settings" ref="infrastructure-settings"
:label="$options.i18n.infrastructureLabel" :label="$options.i18n.infrastructureLabel"
label-for="infrastructure_access_level"
:help-text="$options.i18n.infrastructureHelpText" :help-text="$options.i18n.infrastructureHelpText"
:help-path="infrastructureHelpPath" :help-path="infrastructureHelpPath"
> >
<project-feature-setting <project-feature-setting
id="infrastructure_access_level"
v-model="infrastructureAccessLevel" v-model="infrastructureAccessLevel"
:label="$options.i18n.infrastructureLabel" :label="$options.i18n.infrastructureLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"
@ -1022,10 +1060,12 @@ export default {
<project-setting-row <project-setting-row
ref="releases-settings" ref="releases-settings"
:label="$options.i18n.releasesLabel" :label="$options.i18n.releasesLabel"
label-for="releases_access_level"
:help-text="$options.i18n.releasesHelpText" :help-text="$options.i18n.releasesHelpText"
:help-path="releasesHelpPath" :help-path="releasesHelpPath"
> >
<project-feature-setting <project-feature-setting
id="releases_access_level"
v-model="releasesAccessLevel" v-model="releasesAccessLevel"
:label="$options.i18n.releasesLabel" :label="$options.i18n.releasesLabel"
:options="featureAccessLevelOptions" :options="featureAccessLevelOptions"

View File

@ -5,7 +5,7 @@ module Projects
class TagsController < ::Projects::Registry::ApplicationController class TagsController < ::Projects::Registry::ApplicationController
include PackagesHelper include PackagesHelper
before_action :authorize_destroy_container_image!, only: [:destroy] before_action :authorize_destroy_container_image_tag!, only: [:destroy]
LIMIT = 15 LIMIT = 15

View File

@ -8,7 +8,7 @@ module Mutations
LIMIT = 20 LIMIT = 20
TOO_MANY_TAGS_ERROR_MESSAGE = "Number of tags is greater than #{LIMIT}" TOO_MANY_TAGS_ERROR_MESSAGE = "Number of tags is greater than #{LIMIT}"
authorize :destroy_container_image authorize :destroy_container_image_tag
argument :id, argument :id,
::Types::GlobalIDType[::ContainerRepository], ::Types::GlobalIDType[::ContainerRepository],

View File

@ -5,9 +5,9 @@ module Types
class ContainerRepositoryTag < BasePermissionType class ContainerRepositoryTag < BasePermissionType
graphql_name 'ContainerRepositoryTagPermissions' graphql_name 'ContainerRepositoryTagPermissions'
ability_field :destroy_container_image, ability_field :destroy_container_image_tag,
name: 'destroy_container_repository_tag', name: 'destroy_container_repository_tag',
resolver_method: :destroy_container_image resolver_method: :destroy_container_image_tag
end end
end end
end end

View File

@ -9,6 +9,10 @@ class LfsFileLock < ApplicationRecord
validates :project_id, :user_id, :path, presence: true validates :project_id, :user_id, :path, presence: true
def self.for_path!(path)
find_by!(path: path)
end
def can_be_unlocked_by?(current_user, forced = false) def can_be_unlocked_by?(current_user, forced = false)
return true if current_user.id == user_id return true if current_user.id == user_id

View File

@ -6,7 +6,7 @@ module ContainerRegistry
condition(:protected_for_delete) { @subject.protected_for_delete?(@user) } condition(:protected_for_delete) { @subject.protected_for_delete?(@user) }
rule { protected_for_delete }.policy do rule { protected_for_delete }.policy do
prevent :destroy_container_image prevent :destroy_container_image_tag
end end
end end
end end

View File

@ -565,6 +565,7 @@ class ProjectPolicy < BasePolicy
enable :create_container_image enable :create_container_image
enable :update_container_image enable :update_container_image
enable :destroy_container_image enable :destroy_container_image
enable :destroy_container_image_tag
enable :create_environment enable :create_environment
enable :update_environment enable :update_environment
enable :destroy_environment enable :destroy_environment
@ -784,6 +785,7 @@ class ProjectPolicy < BasePolicy
rule { container_registry_disabled }.policy do rule { container_registry_disabled }.policy do
prevent(*create_read_update_admin_destroy(:container_image)) prevent(*create_read_update_admin_destroy(:container_image))
prevent :destroy_container_image_tag
end end
rule { anonymous & ~public_project }.prevent_all rule { anonymous & ~public_project }.prevent_all

View File

@ -7,6 +7,7 @@ module Auth
:read_container_image, :read_container_image,
:create_container_image, :create_container_image,
:destroy_container_image, :destroy_container_image,
:destroy_container_image_tag,
:update_container_image, :update_container_image,
:admin_container_image, :admin_container_image,
:build_read_container_image, :build_read_container_image,

View File

@ -34,17 +34,15 @@ module Lfs
end end
end end
# rubocop: disable CodeReuse/ActiveRecord
def lock def lock
return @lock if defined?(@lock) return @lock if defined?(@lock)
@lock = if params[:id].present? @lock = if params[:id].present?
project.lfs_file_locks.find(params[:id]) project.lfs_file_locks.find(params[:id])
elsif params[:path].present? elsif params[:path].present?
project.lfs_file_locks.find_by!(path: params[:path]) project.lfs_file_locks.for_path!(params[:path])
end end
end end
# rubocop: enable CodeReuse/ActiveRecord
end end
end end

View File

@ -36,7 +36,7 @@ module Projects
def can_destroy? def can_destroy?
return true if container_expiration_policy return true if container_expiration_policy
can?(current_user, :destroy_container_image, project) can?(current_user, :destroy_container_image_tag, project)
end end
def valid_regex? def valid_regex?

View File

@ -9,7 +9,7 @@ module Projects
@container_repository = container_repository @container_repository = container_repository
unless container_expiration_policy? unless container_expiration_policy?
return error('access denied') unless can?(current_user, :destroy_container_image, project) return error('access denied') unless can?(current_user, :destroy_container_image_tag, project)
end end
@tag_names = params[:tags] @tag_names = params[:tags]

View File

@ -6,6 +6,7 @@ module AntiAbuse
def validate(record) def validate(record)
return if record.errors.include?(:email) return if record.errors.include?(:email)
return unless ::Gitlab::CurrentSettings.enforce_email_subaddress_restrictions
email = record.email email = record.email
@ -24,14 +25,10 @@ module AntiAbuse
private private
def prevent_banned_user_email_reuse?(email) def prevent_banned_user_email_reuse?(email)
return false unless ::Feature.enabled?(:block_banned_user_normalized_email_reuse, ::Feature.current_request)
::Users::BannedUser.by_detumbled_email(email).exists? ::Users::BannedUser.by_detumbled_email(email).exists?
end end
def limit_normalized_email_reuse?(email) def limit_normalized_email_reuse?(email)
return false unless ::Feature.enabled?(:limit_normalized_email_reuse, ::Feature.current_request)
Email.users_by_detumbled_email_count(email) >= NORMALIZED_EMAIL_ACCOUNT_LIMIT Email.users_by_detumbled_email_count(email) >= NORMALIZED_EMAIL_ACCOUNT_LIMIT
end end

View File

@ -31,7 +31,7 @@
= f.label :pages_extra_deployments_default_expiry_seconds, s_('AdminSettings|Default expiration time for parallel deployments (in seconds)'), class: 'label-bold' = f.label :pages_extra_deployments_default_expiry_seconds, s_('AdminSettings|Default expiration time for parallel deployments (in seconds)'), class: 'label-bold'
= f.number_field :pages_extra_deployments_default_expiry_seconds, class: 'form-control gl-form-input' = f.number_field :pages_extra_deployments_default_expiry_seconds, class: 'form-control gl-form-input'
.form-text.gl-text-subtle .form-text.gl-text-subtle
- link = link_to('', help_page_path('user/project/pages/_index.md', anchor: 'parallel-deployments'), target: '_blank', rel: 'noopener noreferrer') - link = link_to('', help_page_path('user/project/pages/parallel_deployments.md'), target: '_blank', rel: 'noopener noreferrer')
= safe_format(s_('AdminSettings|Set the default time after which parallel deployments expire (0 for unlimited). %{link_start}What are parallel deployments%{link_end}?'), tag_pair(link, :link_start, :link_end)) = safe_format(s_('AdminSettings|Set the default time after which parallel deployments expire (0 for unlimited). %{link_start}What are parallel deployments%{link_end}?'), tag_pair(link, :link_start, :link_end))
%h5 %h5
= s_("AdminSettings|Configure Let's Encrypt") = s_("AdminSettings|Configure Let's Encrypt")

View File

@ -22,7 +22,7 @@ module AntiAbuse
attr_reader :banned_user attr_reader :banned_user
def ban_users_with_the_same_detumbled_email! def ban_users_with_the_same_detumbled_email!
return unless Feature.enabled?(:auto_ban_via_detumbled_email, banned_user, type: :gitlab_com_derisk) return unless ::Gitlab::CurrentSettings.enforce_email_subaddress_restrictions
reason = "User #{banned_user.id} was banned with the same detumbled email address" reason = "User #{banned_user.id} was banned with the same detumbled email address"

View File

@ -1,9 +0,0 @@
---
name: auto_ban_via_detumbled_email
feature_issue_url: https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/814
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/166673
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/491796
milestone: '17.5'
group: group::anti-abuse
type: gitlab_com_derisk
default_enabled: false

View File

@ -1,9 +0,0 @@
---
name: block_banned_user_normalized_email_reuse
feature_issue_url: https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/815
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/161122
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/474964
milestone: '17.3'
group: group::anti-abuse
type: gitlab_com_derisk
default_enabled: false

View File

@ -1,9 +0,0 @@
---
name: limit_normalized_email_reuse
feature_issue_url: https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/812
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/167357
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/495124
milestone: '17.6'
group: group::anti-abuse
type: gitlab_com_derisk
default_enabled: false

View File

@ -3,7 +3,7 @@ name: autoflow_issue_events_enabled
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/443486 feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/443486
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/161804 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/161804
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/516169 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/516169
milestone: '17.9' milestone: '17.10'
group: group::environments group: group::environments
type: wip type: wip
default_enabled: false default_enabled: false

View File

@ -0,0 +1,9 @@
---
name: autoflow_merge_request_events_enabled
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/443486
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/179686
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/516168
milestone: '17.10'
group: group::environments
type: wip
default_enabled: false

View File

@ -225,6 +225,16 @@
- 1 - 1
- - cluster_agent - - cluster_agent
- 1 - 1
- - clusters_agents_auto_flow_merge_requests_closed_event
- 1
- - clusters_agents_auto_flow_merge_requests_created_event
- 1
- - clusters_agents_auto_flow_merge_requests_merged_event
- 1
- - clusters_agents_auto_flow_merge_requests_reopened_event
- 1
- - clusters_agents_auto_flow_merge_requests_updated_event
- 1
- - clusters_agents_auto_flow_work_items_closed_event - - clusters_agents_auto_flow_work_items_closed_event
- 1 - 1
- - clusters_agents_auto_flow_work_items_created_event - - clusters_agents_auto_flow_work_items_created_event

View File

@ -23182,7 +23182,7 @@ A tag from a container repository.
| Name | Type | Description | | Name | Type | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| <a id="containerrepositorytagpermissionsdestroycontainerrepositorytag"></a>`destroyContainerRepositoryTag` | [`Boolean!`](#boolean) | If `true`, the user can perform `destroy_container_image` on this resource. | | <a id="containerrepositorytagpermissionsdestroycontainerrepositorytag"></a>`destroyContainerRepositoryTag` | [`Boolean!`](#boolean) | If `true`, the user can perform `destroy_container_image_tag` on this resource. |
### `ContainerTagsExpirationPolicy` ### `ContainerTagsExpirationPolicy`

View File

@ -250,7 +250,7 @@ If you have Kubernetes clusters connected with GitLab, [upgrade your GitLab agen
### Elasticsearch ### Elasticsearch
Before updating GitLab, confirm advanced search migrations are complete by Before updating GitLab, confirm advanced search migrations are complete by
[checking for pending advanced search migrations](background_migrations.md#check-for-pending-advanced-search-migrations). [checking for pending migrations](background_migrations.md#check-for-pending-migrations).
After updating GitLab, you may have to upgrade After updating GitLab, you may have to upgrade
[Elasticsearch if the new version breaks compatibility](../integration/advanced_search/elasticsearch.md#version-requirements). [Elasticsearch if the new version breaks compatibility](../integration/advanced_search/elasticsearch.md#version-requirements).

View File

@ -476,7 +476,9 @@ sudo -u git -H bundle exec rails runner -e production 'puts Gitlab::Database::Ba
{{< /tabs >}} {{< /tabs >}}
## Check for pending advanced search migrations ## Advanced search migrations
### Check for pending migrations
{{< details >}} {{< details >}}
@ -511,3 +513,15 @@ sudo -u git -H bundle exec rake gitlab:elastic:list_pending_migrations
{{< /tab >}} {{< /tab >}}
{{< /tabs >}} {{< /tabs >}}
If you're on a long upgrade path and have many pending migrations, you might want to configure
`Requeue indexing workers` and `Number of shards for non-code indexing` to speed up indexing.
Another option is to ignore the pending migrations and [reindex the instance](../integration/advanced_search/elasticsearch.md#index-the-instance) after you upgrade GitLab to the target version.
You can also disable advanced search during this process with the [`Search with Elasticsearch enabled`](../integration/advanced_search/elasticsearch.md#advanced-search-configuration) setting.
{{< alert type="warning" >}}
Indexing large instances comes with risks.
For more information, see [index large instances efficiently](../integration/advanced_search/elasticsearch.md#index-large-instances-efficiently).
{{< /alert >}}

View File

@ -341,12 +341,12 @@ The limit varies depending on your plan and the number of seats in your subscrip
### Other limits ### Other limits
| Setting | Default for GitLab.com | | Setting | Default for GitLab.com |
|:---------------------------------------------------------------|:-----------------------| |:--------------------------------------------------------------------|:-----------------------|
| Number of webhooks | 100 for each project, 50 for each group (subgroup webhooks are not counted towards parent group limits ) | | Number of webhooks | 100 for each project, 50 for each group (subgroup webhooks are not counted towards parent group limits ) |
| Maximum payload size | 25 MB | | Maximum payload size | 25 MB |
| Timeout | 10 seconds | | Timeout | 10 seconds |
| [Multiple Pages deployments](../project/pages/_index.md#limits) | 100 extra deployments (Premium tier), 500 extra deployments (Ultimate tier) | | [Parallel Pages deployments](../project/pages/parallel_deployments.md#limits) | 100 extra deployments (Premium tier), 500 extra deployments (Ultimate tier) |
For self-managed instance limits, see: For self-managed instance limits, see:

View File

@ -104,7 +104,7 @@ To improve your security, try these features:
| Feature | Tier | Add-on | Offering | Status | | Feature | Tier | Add-on | Offering | Status |
| ------- | ---- | ------ | -------- | ------ | | ------- | ---- | ------ | -------- | ------ |
| [GitLab Duo Chat](../gitlab_duo_chat/_index.md) | Premium, Ultimate | GitLab Duo Pro or Enterprise | GitLab.com, Self-managed, GitLab Dedicated | General availability | | [GitLab Duo Chat](../gitlab_duo_chat/_index.md) | Premium, Ultimate | GitLab Duo Pro or Enterprise | GitLab.com, Self-managed, GitLab Dedicated | General availability |
| [GitLab Duo Self-Hosted](../../administration/gitlab_duo_self_hosted/_index.md) | Ultimate | GitLab Duo Enterprise | Self-managed | Beta | | [GitLab Duo Self-Hosted](../../administration/gitlab_duo_self_hosted/_index.md) | Ultimate | GitLab Duo Enterprise | Self-managed | General availability |
| [GitLab Duo Workflow](../duo_workflow/_index.md) | Ultimate | - | GitLab.com | Experiment | | [GitLab Duo Workflow](../duo_workflow/_index.md) | Ultimate | - | GitLab.com | Experiment |
| [Issue Description Generation](../project/issues/managing_issues.md#populate-an-issue-with-issue-description-generation) | Ultimate | GitLab Duo Enterprise | GitLab.com | Experiment | | [Issue Description Generation](../project/issues/managing_issues.md#populate-an-issue-with-issue-description-generation) | Ultimate | GitLab Duo Enterprise | GitLab.com | Experiment |
| [Discussion Summary](../discussions/_index.md#summarize-issue-discussions-with-duo-chat) | Ultimate | GitLab Duo Enterprise | GitLab.com, Self-managed, GitLab Dedicated | General availability | | [Discussion Summary](../discussions/_index.md#summarize-issue-discussions-with-duo-chat) | Ultimate | GitLab Duo Enterprise | GitLab.com, Self-managed, GitLab Dedicated | General availability |

View File

@ -205,13 +205,6 @@ Prerequisites:
## Expiring deployments ## Expiring deployments
{{< details >}}
- Tier: Premium, Ultimate
- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
{{< /details >}}
{{< history >}} {{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/162826) in GitLab 17.4. - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/162826) in GitLab 17.4.
@ -233,10 +226,6 @@ deploy-pages:
- public - public
``` ```
By default, [parallel deployments](#parallel-deployments) expire
automatically after 24 hours.
To disable this behavior, set `pages.expire_in` to `never`.
Expired deployments are stopped by a cron job that runs every 10 minutes. Expired deployments are stopped by a cron job that runs every 10 minutes.
Stopped deployments are subsequently deleted by another cron job that also Stopped deployments are subsequently deleted by another cron job that also
runs every 10 minutes. To recover it, follow the steps described in runs every 10 minutes. To recover it, follow the steps described in
@ -263,169 +252,6 @@ To recover a stopped deployment that has not yet been deleted:
list. list.
1. Expand the deployment you want to recover and select **Restore**. 1. Expand the deployment you want to recover and select **Restore**.
## Parallel deployments
{{< details >}}
- Tier: Premium, Ultimate
{{< /details >}}
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129534) in GitLab 16.7 as an [experiment](../../../policy/development_stages_support.md) [with a flag](../../feature_flags.md) named `pages_multiple_versions_setting`. Disabled by default.
- [Renamed](https://gitlab.com/gitlab-org/gitlab/-/issues/480195) from "multiple deployments" to "parallel deployments" in GitLab 17.4.
- [Enabled on GitLab.com, GitLab Self-Managed, and GitLab Dedicated](https://gitlab.com/gitlab-org/gitlab/-/issues/422145) in GitLab 17.4.
- [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/502219) to remove the project setting in GitLab 17.7.
- [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/507423) to allow periods in `path_prefix` in GitLab 17.8.
- [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/500000) to allow variables when passed to `publish` property in GitLab 17.9.
- [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/487161) in GitLab 17.9. Feature flag `pages_multiple_versions_setting` removed.
{{< /history >}}
Use the [`pages.path_prefix`](../../../ci/yaml/_index.md#pagespagespath_prefix) CI/CD option to configure a prefix for the GitLab Pages URL.
A prefix allows you to differentiate between multiple GitLab Pages deployments:
- Main deployment: a Pages deployment created with a blank `path_prefix`.
- Parallel deployment: a Pages deployment created with a non-blank `path_prefix`
The value of `pages.path_prefix` is:
- Converted to lowercase.
- Shortened to 63 bytes.
- Any character except numbers (`0-9`), letters (`a-z`) and periods (`.`) is replaced with a hyphen (`-`).
- Leading and trailing hyphens (`-`) and period (`.`) are removed.
### Example configuration
Consider a project such as `https://gitlab.example.com/namespace/project`. By default, its main Pages deployment can be accessed through:
- When using a [unique domain](#unique-domains): `https://project-123456.gitlab.io/`.
- When not using a unique domain: `https://namespace.gitlab.io/project`.
If a `pages.path_prefix` is configured to the project branch names,
like `path_prefix = $CI_COMMIT_BRANCH`, and there's a
branch named `username/testing_feature`, this parallel Pages deployment would be accessible through:
- When using a [unique domain](#unique-domains): `https://project-123456.gitlab.io/username-testing-feature`.
- When not using a unique domain: `https://namespace.gitlab.io/project/username-testing-feature`.
### Limits
The number of parallel deployments is limited by the root-level namespace. For
specific limits for:
- GitLab.com, see [Other limits](../../gitlab_com/_index.md#other-limits).
- GitLab Self-Managed, see
[Number of parallel Pages deployments](../../../administration/instance_limits.md#number-of-parallel-pages-deployments).
To immediately reduce the number of active deployments in your namespace,
delete some deployments. For more information, see
[Delete a deployment](#delete-a-deployment).
To configure an expiry time to automatically
delete older deployments, see
[Expiring deployments](#expiring-deployments).
### Expiration
By default, parallel deployments expire after 24 hours, after which they are
deleted. If you're using a self-hosted instance, your instance admin can
[configure a different default duration](../../../administration/pages/_index.md#configure-the-default-expiry-for-parallel-deployments).
To customize the expiry time, [configure `pages.expire_in`](#expiring-deployments).
To prevent deployments from automatically expiring, set `pages.expire_in` to
`never`.
### Path clash
`pages.path_prefix` can take dynamic values from [CI/CD variables](../../../ci/variables/_index.md)
that can create pages deployments which could clash with existing paths in your site.
For example, given an existing GitLab Pages site with the following paths:
```plaintext
/index.html
/documents/index.html
```
If a `pages.path_prefix` is `documents`, that version will override the existing path.
In other words, `https://namespace.gitlab.io/project/documents/index.html` will point to the
`/index.html` on the `documents` deployment of the site, instead of `documents/index.html` of the
`main` deployment of the site.
Mixing [CI/CD variables](../../../ci/variables/_index.md) with other strings can reduce the path clash
possibility. For example:
```yaml
deploy-pages:
stage: deploy
script:
- echo "Pages accessible through ${CI_PAGES_URL}"
variables:
PAGES_PREFIX: "" # No prefix by default (main)
pages: # specifies that this is a Pages job
path_prefix: "$PAGES_PREFIX"
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run on default branch (with default PAGES_PREFIX)
- if: $CI_COMMIT_BRANCH == "staging" # Run on main (with default PAGES_PREFIX)
variables:
PAGES_PREFIX: '_stg' # Prefix with _stg for the staging branch
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # Conditionally change the prefix for Merge Requests
when: manual # Run pages manually on Merge Requests
variables:
PAGES_PREFIX: 'mr-$CI_MERGE_REQUEST_IID' # Prefix with the mr-<iid>, like `mr-123`
```
Some other examples of mixing [variables](../../../ci/variables/_index.md) with strings for dynamic prefixes:
- `pages.path_prefix: 'mr-$CI_COMMIT_REF_SLUG'`: Branch or tag name prefixed with `mr-`, like `mr-branch-name`.
- `pages.path_prefix: '_${CI_MERGE_REQUEST_IID}_'`: Merge request number
prefixed ans suffixed with `_`, like `_123_`.
The previous YAML example uses [user-defined job names](#user-defined-job-names).
### Use parallel deployments to create Pages environments
You can use parallel GitLab Pages deployments to create a new [environment](../../../ci/environments/_index.md).
For example:
```yaml
deploy-pages:
stage: deploy
script:
- echo "Pages accessible through ${CI_PAGES_URL}"
variables:
PAGES_PREFIX: "" # no prefix by default (master)
pages: # specifies that this is a Pages job
path_prefix: "$PAGES_PREFIX"
environment:
name: "Pages ${PAGES_PREFIX}"
url: $CI_PAGES_URL
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == "staging" # ensure to run on master (with default PAGES_PREFIX)
variables:
PAGES_PREFIX: '_stg' # prefix with _stg for the staging branch
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # conditionally change the prefix on Merge Requests
when: manual # run pages manually on Merge Requests
variables:
PAGES_PREFIX: 'mr-$CI_MERGE_REQUEST_IID' # prefix with the mr-<iid>, like `mr-123`
```
With this configuration, users will have the access to each GitLab Pages deployment through the UI.
When using [environments](../../../ci/environments/_index.md) for pages, all pages environments are
listed on the project environment list.
You can also [group similar environments](../../../ci/environments/_index.md#group-similar-environments) together.
The previous YAML example uses [user-defined job names](#user-defined-job-names).
### Delete a Deployment ### Delete a Deployment
To delete a deployment: To delete a deployment:
@ -442,11 +268,6 @@ Stopped deployments are deleted by a cron job running every 10 minutes.
To restore a stopped deployment that has not been deleted yet, see To restore a stopped deployment that has not been deleted yet, see
[Recover a stopped deployment](#recover-a-stopped-deployment). [Recover a stopped deployment](#recover-a-stopped-deployment).
#### Auto-clean
Parallel Pages deployments, created by a merge request with a `path_prefix`, are automatically deleted when the
merge request is closed or merged.
## User-defined job names ## User-defined job names
{{< history >}} {{< history >}}
@ -493,3 +314,8 @@ deployment is triggered:
pages: pages:
pages: false pages: false
``` ```
## Parallel deployments
To create multiple deployments for your project at the same time, for example to
create review apps, view the documentation on [Parallel Deployments](parallel_deployments.md).

View File

@ -0,0 +1,240 @@
---
stage: Plan
group: Knowledge
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
title: GitLab Pages parallel deployments
---
{{< details >}}
- Tier: Premium, Ultimate
- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
{{< /details >}}
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129534) in GitLab 16.7 as an [experiment](../../../policy/development_stages_support.md) [with a flag](../../feature_flags.md) named `pages_multiple_versions_setting`. Disabled by default.
- [Renamed](https://gitlab.com/gitlab-org/gitlab/-/issues/480195) from "multiple deployments" to "parallel deployments" in GitLab 17.4.
- [Enabled on GitLab.com, GitLab Self-Managed, and GitLab Dedicated](https://gitlab.com/gitlab-org/gitlab/-/issues/422145) in GitLab 17.4.
- [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/502219) to remove the project setting in GitLab 17.7.
- [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/507423) to allow periods in `path_prefix` in GitLab 17.8.
- [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/500000) to allow variables when passed to `publish` property in GitLab 17.9.
- [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/487161) in GitLab 17.9. Feature flag `pages_multiple_versions_setting` removed.
{{< /history >}}
With parallel deployments, you can publish multiple versions of your [GitLab Pages](_index.md)
site at the same time. Each version has its own unique URL based on a path prefix you specify.
Use parallel deployments to:
- Enhance your workflow for testing changes in development branches before merging
to production.
- Share working previews with stakeholders for feedback.
- Maintain documentation for multiple software versions simultaneously.
- Publish localized content for different audiences.
- Create staging environments for review before final publication.
Each version of your site gets its own URL based on a path prefix that you specify.
Control how long these parallel deployments exist.
They expire after 24 hours by default, but you can customize this duration to fit your review timeline.
### Create a parallel deployment
Prerequisites:
- The root-level namespace must have available [parallel deployment slots](../../gitlab_com/_index.md#other-limits).
To create a parallel deployment:
1. In your `.gitlab-ci.yml` file, add a Pages job with a `path_prefix`:
```yaml
pages:
stage: deploy
script:
- echo "Pages accessible through ${CI_PAGES_URL}/${CI_COMMIT_BRANCH}"
pages:
path_prefix: "$CI_COMMIT_BRANCH"
artifacts:
paths:
- public
```
The `path_prefix` value:
- Is converted to lowercase.
- Can contain numbers (`0-9`), letters (`a-z`), and periods (`.`).
- Is replaced with hyphens (`-`) for any other characters.
- Cannot start or end with hyphens (`-`) or periods (`.`), so they are removed.
- Must be 63 bytes or shorter. Anything longer is trimmed.
1. Optional. If you want dynamic prefixes, use
[CI/CD variables](../../../ci/variables/where_variables_can_be_used.md#gitlab-ciyml-file) in your `path_prefix`.
For example:
```yaml
pages:
path_prefix: "mr-$CI_MERGE_REQUEST_IID" # Results in paths like mr-123
```
1. Optional. To set an expiry time for the deployment, add `expire_in`:
```yaml
pages:
pages:
path_prefix: "$CI_COMMIT_BRANCH"
expire_in: 1 week
```
By default, parallel deployments [expire](#expiration) after 24 hours.
1. Commit your changes and push to your repository.
The deployment is accessible at:
- With a [unique domain](_index.md#unique-domains): `https://project-123456.gitlab.io/your-prefix-name`.
- Without a unique domain: `https://namespace.gitlab.io/project/your-prefix-name`.
The URL path between the site domain and public directory is determined by the `path_prefix`.
For example, if your main deployment has content at `/index.html`, a parallel deployment with prefix
`staging` can access that same content at `/staging/index.html`.
To prevent path clashes, avoid using path prefixes that match the names of existing folders in your site.
For more information, see [Path clash](#path-clash).
### Example configuration
Consider a project such as `https://gitlab.example.com/namespace/project`. By default, its main Pages deployment can be accessed through:
- When using a [unique domain](_index.md#unique-domains): `https://project-123456.gitlab.io/`.
- When not using a unique domain: `https://namespace.gitlab.io/project`.
If a `pages.path_prefix` is configured to the project branch names,
like `path_prefix = $CI_COMMIT_BRANCH`, and there's a
branch named `username/testing_feature`, this parallel Pages deployment would be accessible through:
- When using a [unique domain](_index.md#unique-domains): `https://project-123456.gitlab.io/username-testing-feature`.
- When not using a unique domain: `https://namespace.gitlab.io/project/username-testing-feature`.
### Limits
The number of parallel deployments is limited by the root-level namespace. For
specific limits for:
- GitLab.com, see [Other limits](../../gitlab_com/_index.md#other-limits).
- GitLab Self-Managed, see
[Number of parallel Pages deployments](../../../administration/instance_limits.md#number-of-parallel-pages-deployments).
To immediately reduce the number of active deployments in your namespace,
delete some deployments. For more information, see
[Delete a deployment](_index.md#delete-a-deployment).
To configure an expiry time to automatically
delete older deployments, see
[Expiring deployments](_index.md#expiring-deployments).
### Expiration
By default, parallel deployments [expire](_index.md#expiring-deployments) after 24 hours,
after which they are deleted. If you're using a self-hosted instance, your instance admin can
[configure a different default duration](../../../administration/pages/_index.md#configure-the-default-expiry-for-parallel-deployments).
To customize the expiry time, [configure `pages.expire_in`](_index.md#expiring-deployments).
To prevent deployments from automatically expiring, set `pages.expire_in` to
`never`.
### Path clash
`pages.path_prefix` can take dynamic values from [CI/CD variables](../../../ci/variables/_index.md)
that can create pages deployments which could clash with existing paths in your site.
For example, given an existing GitLab Pages site with the following paths:
```plaintext
/index.html
/documents/index.html
```
If a `pages.path_prefix` is `documents`, that version overrides the existing path.
In other words, `https://namespace.gitlab.io/project/documents/index.html` points to the
`/index.html` on the `documents` deployment of the site, instead of `documents/index.html` of the
`main` deployment of the site.
Mixing [CI/CD variables](../../../ci/variables/_index.md) with other strings can reduce the path clash
possibility. For example:
```yaml
deploy-pages:
stage: deploy
script:
- echo "Pages accessible through ${CI_PAGES_URL}"
variables:
PAGES_PREFIX: "" # No prefix by default (main)
pages: # specifies that this is a Pages job
path_prefix: "$PAGES_PREFIX"
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # Run on default branch (with default PAGES_PREFIX)
- if: $CI_COMMIT_BRANCH == "staging" # Run on main (with default PAGES_PREFIX)
variables:
PAGES_PREFIX: '_stg' # Prefix with _stg for the staging branch
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # Conditionally change the prefix for Merge Requests
when: manual # Run pages manually on Merge Requests
variables:
PAGES_PREFIX: 'mr-$CI_MERGE_REQUEST_IID' # Prefix with the mr-<iid>, like `mr-123`
```
Some other examples of mixing [variables](../../../ci/variables/_index.md) with strings for dynamic prefixes:
- `pages.path_prefix: 'mr-$CI_COMMIT_REF_SLUG'`: Branch or tag name prefixed with `mr-`, like `mr-branch-name`.
- `pages.path_prefix: '_${CI_MERGE_REQUEST_IID}_'`: Merge request number
prefixed ans suffixed with `_`, like `_123_`.
The previous YAML example uses [user-defined job names](_index.md#user-defined-job-names).
### Use parallel deployments to create Pages environments
You can use parallel GitLab Pages deployments to create a new [environment](../../../ci/environments/_index.md).
For example:
```yaml
deploy-pages:
stage: deploy
script:
- echo "Pages accessible through ${CI_PAGES_URL}"
variables:
PAGES_PREFIX: "" # no prefix by default (master)
pages: # specifies that this is a Pages job
path_prefix: "$PAGES_PREFIX"
environment:
name: "Pages ${PAGES_PREFIX}"
url: $CI_PAGES_URL
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == "staging" # ensure to run on master (with default PAGES_PREFIX)
variables:
PAGES_PREFIX: '_stg' # prefix with _stg for the staging branch
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # conditionally change the prefix on Merge Requests
when: manual # run pages manually on Merge Requests
variables:
PAGES_PREFIX: 'mr-$CI_MERGE_REQUEST_IID' # prefix with the mr-<iid>, like `mr-123`
```
With this configuration, users will have the access to each GitLab Pages deployment through the UI.
When using [environments](../../../ci/environments/_index.md) for pages, all pages environments are
listed on the project environment list.
You can also [group similar environments](../../../ci/environments/_index.md#group-similar-environments) together.
The previous YAML example uses [user-defined job names](_index.md#user-defined-job-names).
#### Auto-clean
Parallel Pages deployments, created by a merge request with a `path_prefix`, are automatically deleted when the
merge request is closed or merged.

View File

@ -180,7 +180,7 @@ module API
requires :tag_name, type: String, desc: 'The name of the tag' requires :tag_name, type: String, desc: 'The name of the tag'
end end
delete ':id/registry/repositories/:repository_id/tags/:tag_name', requirements: REPOSITORY_ENDPOINT_REQUIREMENTS do delete ':id/registry/repositories/:repository_id/tags/:tag_name', requirements: REPOSITORY_ENDPOINT_REQUIREMENTS do
authorize_destroy_container_image! authorize_destroy_container_image_tag!
result = ::Projects::ContainerRepository::DeleteTagsService result = ::Projects::ContainerRepository::DeleteTagsService
.new(repository.project, current_user, tags: [declared_params[:tag_name]]) .new(repository.project, current_user, tags: [declared_params[:tag_name]])
@ -205,8 +205,8 @@ module API
authorize! :read_container_image, repository authorize! :read_container_image, repository
end end
def authorize_destroy_container_image! def authorize_destroy_container_image_tag!
authorize! :destroy_container_image, repository authorize! :destroy_container_image_tag, tag
end end
def authorize_admin_container_image! def authorize_admin_container_image!

View File

@ -82,6 +82,10 @@ RSpec.describe Projects::Registry::TagsController do
end end
describe 'POST destroy' do describe 'POST destroy' do
before do
allow(controller).to receive(:authorize_destroy_container_image_tag!).and_call_original
end
context 'when user has access to registry' do context 'when user has access to registry' do
before do before do
project.add_developer(user) project.add_developer(user)
@ -96,12 +100,16 @@ RSpec.describe Projects::Registry::TagsController do
expect_delete_tags(%w[rc1]) expect_delete_tags(%w[rc1])
destroy_tag('rc1') destroy_tag('rc1')
expect(controller).to have_received(:authorize_destroy_container_image_tag!)
end end
it 'makes it possible to delete a tag that ends with a dot' do it 'makes it possible to delete a tag that ends with a dot' do
expect_delete_tags(%w[test.]) expect_delete_tags(%w[test.])
destroy_tag('test.') destroy_tag('test.')
expect(controller).to have_received(:authorize_destroy_container_image_tag!)
end end
it 'tracks the event', :snowplow do it 'tracks the event', :snowplow do
@ -114,6 +122,21 @@ RSpec.describe Projects::Registry::TagsController do
end end
end end
context 'when user cannot destroy image tags' do
before do
project.add_developer(user)
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(user, :destroy_container_image_tag, project).and_return(false)
end
it 'returns not_found' do
destroy_tag('test.')
expect(controller).to have_received(:authorize_destroy_container_image_tag!)
expect(response).to have_gitlab_http_status(:not_found)
end
end
private private
def destroy_tag(name) def destroy_tag(name)

View File

@ -164,6 +164,7 @@ describe('Pipeline New Form', () => {
expect(mockCiConfigVariables).toHaveBeenCalled(); expect(mockCiConfigVariables).toHaveBeenCalled();
}); });
}); });
describe('when the ciInputsForPipelines flag is enabled', () => { describe('when the ciInputsForPipelines flag is enabled', () => {
beforeEach(async () => { beforeEach(async () => {
mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse);

View File

@ -1,17 +1,24 @@
import { GlFormGroup, GlLoadingIcon } from '@gitlab/ui'; import { GlFormGroup, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import Vue from 'vue'; import Vue from 'vue';
import { fetchPolicies } from '~/lib/graphql'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { fetchPolicies } from '~/lib/graphql';
import { reportToSentry } from '~/ci/utils'; import { reportToSentry } from '~/ci/utils';
import ciConfigVariablesQuery from '~/ci/pipeline_new/graphql/queries/ci_config_variables.graphql'; import ciConfigVariablesQuery from '~/ci/pipeline_new/graphql/queries/ci_config_variables.graphql';
import { VARIABLE_TYPE } from '~/ci/pipeline_new/constants';
import PipelineVariablesForm from '~/ci/pipeline_new/components/pipeline_variables_form.vue'; import PipelineVariablesForm from '~/ci/pipeline_new/components/pipeline_variables_form.vue';
Vue.use(VueApollo); Vue.use(VueApollo);
jest.mock('~/ci/utils'); jest.mock('~/ci/utils');
jest.mock('@gitlab/ui/dist/utils', () => ({
GlBreakpointInstance: {
getBreakpointSize: jest.fn(),
},
}));
describe('PipelineVariablesForm', () => { describe('PipelineVariablesForm', () => {
let wrapper; let wrapper;
@ -19,16 +26,41 @@ describe('PipelineVariablesForm', () => {
let mockCiConfigVariables; let mockCiConfigVariables;
const defaultProps = { const defaultProps = {
projectPath: 'group/project',
defaultBranch: 'main', defaultBranch: 'main',
isMaintainer: true,
projectPath: 'group/project',
refParam: 'feature', refParam: 'feature',
settingsLink: 'link/to/settings',
}; };
const createComponent = async ({ props = {} } = {}) => { const configVariablesWithOptions = [
{
key: 'VAR_WITH_OPTIONS',
value: 'option1',
description: 'Variable with options',
valueOptions: ['option1', 'option2', 'option3'],
},
{
key: 'SIMPLE_VAR',
value: 'simple-value',
description: 'Simple variable',
valueOptions: [],
},
];
const createComponent = async ({ props = {}, configVariables = [] } = {}) => {
mockCiConfigVariables = jest.fn().mockResolvedValue({
data: {
project: {
ciConfigVariables: configVariables,
},
},
});
const handlers = [[ciConfigVariablesQuery, mockCiConfigVariables]]; const handlers = [[ciConfigVariablesQuery, mockCiConfigVariables]];
mockApollo = createMockApollo(handlers); mockApollo = createMockApollo(handlers);
wrapper = shallowMount(PipelineVariablesForm, { wrapper = shallowMountExtended(PipelineVariablesForm, {
apolloProvider: mockApollo, apolloProvider: mockApollo,
propsData: { ...defaultProps, ...props }, propsData: { ...defaultProps, ...props },
}); });
@ -36,8 +68,11 @@ describe('PipelineVariablesForm', () => {
await waitForPromises(); await waitForPromises();
}; };
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findForm = () => wrapper.findComponent(GlFormGroup); const findForm = () => wrapper.findComponent(GlFormGroup);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row-container');
const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key-field');
const findRemoveButton = () => wrapper.findByTestId('remove-ci-variable-row');
beforeEach(() => { beforeEach(() => {
mockCiConfigVariables = jest.fn().mockResolvedValue({ mockCiConfigVariables = jest.fn().mockResolvedValue({
@ -65,15 +100,47 @@ describe('PipelineVariablesForm', () => {
}); });
}); });
describe('form initialization', () => {
it('adds an empty variable row', async () => {
await createComponent();
expect(findVariableRows()).toHaveLength(1);
});
it('initializes with variables from config', async () => {
await createComponent({ configVariables: configVariablesWithOptions });
const keyInputs = findKeyInputs();
expect(keyInputs.length).toBeGreaterThanOrEqual(1);
// Check if at least one of the expected variables exists
const keys = keyInputs.wrappers.map((w) => w.props('value'));
expect(keys.some((key) => ['VAR_WITH_OPTIONS', 'SIMPLE_VAR'].includes(key))).toBe(true);
});
it('initializes with variables from props', async () => {
await createComponent({
props: {
variableParams: { CUSTOM_VAR: 'custom-value' },
},
});
const keyInputs = findKeyInputs();
expect(keyInputs.length).toBeGreaterThanOrEqual(1);
// At least the empty row should exist
const emptyRowExists = keyInputs.wrappers.some((w) => w.props('value') === '');
expect(emptyRowExists).toBe(true);
});
});
describe('query configuration', () => { describe('query configuration', () => {
it('has correct apollo query configuration', async () => { it('has correct apollo query configuration', async () => {
await createComponent(); await createComponent();
const { apollo } = wrapper.vm.$options; const { apollo } = wrapper.vm.$options;
expect(apollo.ciConfigVariables).toMatchObject({ expect(apollo.ciConfigVariables.fetchPolicy).toBe(fetchPolicies.NO_CACHE);
fetchPolicy: fetchPolicies.NO_CACHE, expect(apollo.ciConfigVariables.query).toBe(ciConfigVariablesQuery);
query: ciConfigVariablesQuery,
});
}); });
it('makes query with correct variables', async () => { it('makes query with correct variables', async () => {
@ -86,10 +153,73 @@ describe('PipelineVariablesForm', () => {
}); });
it('reports to sentry when query fails', async () => { it('reports to sentry when query fails', async () => {
mockCiConfigVariables = jest.fn().mockRejectedValue(new Error('GraphQL error')); const error = new Error('GraphQL error');
await createComponent(); await createComponent();
expect(reportToSentry).toHaveBeenCalledWith('PipelineVariablesForm', expect.any(Error)); wrapper.vm.$options.apollo.ciConfigVariables.error.call(wrapper.vm, error);
expect(reportToSentry).toHaveBeenCalledWith('PipelineVariablesForm', error);
});
});
describe('variable rows', () => {
it('emits variables-updated event when variables change', async () => {
await createComponent();
expect(wrapper.emitted('variables-updated')).toHaveLength(1);
wrapper.vm.$options.watch.variables.handler.call(wrapper.vm, [
{ key: 'TEST_KEY', value: 'test_value', variableType: VARIABLE_TYPE },
]);
expect(wrapper.emitted('variables-updated')).toHaveLength(2);
});
});
describe('variable removal', () => {
it('shows remove button with correct aria-label', async () => {
await createComponent({
props: { variableParams: { VAR1: 'value1', VAR2: 'value2' } },
});
expect(findRemoveButton().exists()).toBe(true);
expect(findRemoveButton().attributes('aria-label')).toBe('Remove variable');
});
});
describe('responsive design', () => {
it('uses secondary button category on mobile', async () => {
GlBreakpointInstance.getBreakpointSize.mockReturnValue('sm');
await createComponent({
props: { variableParams: { VAR1: 'value1' } },
});
expect(findRemoveButton().exists()).toBe(true);
expect(findRemoveButton().props('category')).toBe('secondary');
});
it('uses tertiary button category on desktop', async () => {
GlBreakpointInstance.getBreakpointSize.mockReturnValue('md');
await createComponent({
props: { variableParams: { VAR1: 'value1' } },
});
expect(findRemoveButton().exists()).toBe(true);
expect(findRemoveButton().props('category')).toBe('tertiary');
});
});
describe('settings link', () => {
it('passes correct props for maintainers', async () => {
await createComponent({ props: { isMaintainer: true } });
expect(wrapper.props('isMaintainer')).toBe(true);
expect(wrapper.props('settingsLink')).toBe(defaultProps.settingsLink);
});
it('passes correct props for non-maintainers', async () => {
await createComponent({ props: { isMaintainer: false } });
expect(wrapper.props('isMaintainer')).toBe(false);
}); });
}); });
}); });

View File

@ -1,14 +1,21 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { GlIcon } from '@gitlab/ui'; import { GlIcon, GlFormGroup } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import projectSettingRow from '~/pages/projects/shared/permissions/components/project_setting_row.vue'; import projectSettingRow from '~/pages/projects/shared/permissions/components/project_setting_row.vue';
describe('Project Setting Row', () => { describe('Project Setting Row', () => {
let wrapper; let wrapper;
const findLabel = () => wrapper.findByTestId('project-settings-row-label');
const findHelpText = () => wrapper.findByTestId('project-settings-row-help-text');
const createComponent = (customProps = {}) => { const createComponent = (customProps = {}) => {
const propsData = { ...customProps }; return shallowMountExtended(projectSettingRow, {
return shallowMount(projectSettingRow, { propsData }); propsData: {
...customProps,
},
stubs: { GlFormGroup },
});
}; };
beforeEach(() => { beforeEach(() => {
@ -19,25 +26,26 @@ describe('Project Setting Row', () => {
wrapper = createComponent({ label: 'Test label' }); wrapper = createComponent({ label: 'Test label' });
await nextTick(); await nextTick();
expect(wrapper.find('label').text()).toEqual('Test label'); expect(findLabel().text()).toEqual('Test label');
}); });
it('should hide the label if it is not set', () => { it('should hide the label if it is not set', () => {
expect(wrapper.find('label').exists()).toBe(false); expect(findLabel().exists()).toBe(false);
}); });
it('should apply gl-text-disabled class to label when locked', async () => { it('should apply gl-text-disabled class to label when locked', async () => {
wrapper = createComponent({ label: 'Test label', locked: true }); wrapper = createComponent({ label: 'Test label', locked: true });
await nextTick(); await nextTick();
expect(wrapper.find('label').classes()).toContain('gl-text-disabled'); expect(findLabel().classes()).toContain('gl-text-disabled');
}); });
it('should render default slot content', () => { it('should render default slot content', () => {
wrapper = shallowMount(projectSettingRow, { wrapper = shallowMountExtended(projectSettingRow, {
slots: { slots: {
'label-icon': GlIcon, 'label-icon': GlIcon,
}, },
stubs: { GlFormGroup },
}); });
expect(wrapper.findComponent(GlIcon).exists()).toBe(true); expect(wrapper.findComponent(GlIcon).exists()).toBe(true);
}); });
@ -63,10 +71,10 @@ describe('Project Setting Row', () => {
wrapper = createComponent({ helpText: 'Test text' }); wrapper = createComponent({ helpText: 'Test text' });
await nextTick(); await nextTick();
expect(wrapper.find('span').text()).toEqual('Test text'); expect(findHelpText().text()).toEqual('Test text');
}); });
it('should hide the help text if it is set', () => { it('should hide the help text if it is set', () => {
expect(wrapper.find('span').exists()).toBe(false); expect(findHelpText().exists()).toBe(false);
}); });
}); });

View File

@ -913,6 +913,7 @@ describe('Settings Panel', () => {
helpPath: '/help/user/ai_features', helpPath: '/help/user/ai_features',
helpText: 'Use AI-powered features in this project.', helpText: 'Use AI-powered features in this project.',
label: 'GitLab Duo', label: 'GitLab Duo',
labelFor: null,
locked: false, locked: false,
}); });
}); });
@ -985,6 +986,7 @@ describe('Settings Panel', () => {
helpPath: '/help/user/duo_amazon_q/_index.md', helpPath: '/help/user/duo_amazon_q/_index.md',
helpText: 'This project can use Amazon Q.', helpText: 'This project can use Amazon Q.',
label: 'Amazon Q', label: 'Amazon Q',
labelFor: null,
locked: false, locked: false,
}); });
}); });

View File

@ -11,7 +11,7 @@ RSpec.describe Mutations::ContainerRepositories::DestroyTags, feature_category:
let(:id) { repository.to_global_id } let(:id) { repository.to_global_id }
let(:current_user) { create(:user) } let(:current_user) { create(:user) }
specify { expect(described_class).to require_graphql_authorizations(:destroy_container_image) } specify { expect(described_class).to require_graphql_authorizations(:destroy_container_image_tag) }
describe '#resolve' do describe '#resolve' do
let(:tags) { %w[A C D E] } let(:tags) { %w[A C D E] }

View File

@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe LfsFileLock do RSpec.describe LfsFileLock, feature_category: :source_code_management do
let_it_be(:lfs_file_lock, reload: true) { create(:lfs_file_lock) } let_it_be(:lfs_file_lock, reload: true) { create(:lfs_file_lock) }
subject { lfs_file_lock } subject { lfs_file_lock }
@ -57,4 +57,18 @@ RSpec.describe LfsFileLock do
end end
end end
end end
describe '#for_path!(path)' do
context 'when the lfs_file_lock exists' do
it 'returns the lfs file lock' do
expect(described_class.for_path!(lfs_file_lock.path)).to eq(lfs_file_lock)
end
end
context 'when the path does not exist' do
it 'raises an error' do
expect { described_class.for_path!('not_a_real_path.rb') }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end end

View File

@ -9036,6 +9036,10 @@ RSpec.describe User, feature_category: :user_profile do
subject(:validate) { new_user.validate } subject(:validate) { new_user.validate }
before do
stub_application_setting(enforce_email_subaddress_restrictions: true)
end
shared_examples 'adds a validation error' do |reason| shared_examples 'adds a validation error' do |reason|
specify do specify do
expect(::Gitlab::AppLogger).to receive(:info).with( expect(::Gitlab::AppLogger).to receive(:info).with(
@ -9090,9 +9094,9 @@ RSpec.describe User, feature_category: :user_profile do
end end
end end
context 'when the feature flag is disabled' do context 'when the enforce_email_subaddress_restrictions application setting is disabled' do
before do before do
stub_feature_flags(limit_normalized_email_reuse: false) stub_application_setting(enforce_email_subaddress_restrictions: false)
end end
it 'does not perform the check' do it 'does not perform the check' do
@ -9158,16 +9162,14 @@ RSpec.describe User, feature_category: :user_profile do
end end
end end
context 'when feature flag is disabled' do context 'when enforce_email_subaddress_restrictions application setting is disabled' do
before do before do
stub_feature_flags(block_banned_user_normalized_email_reuse: false) stub_application_setting(enforce_email_subaddress_restrictions: false)
end end
it 'does not perform the check' do it 'does not perform the check' do
expect(::Users::BannedUser).not_to receive(:by_detumbled_email) expect(::Users::BannedUser).not_to receive(:by_detumbled_email)
end end
it_behaves_like 'checking normalized email reuse limit'
end end
end end

View File

@ -11,7 +11,7 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
subject { described_class.new(user, tag) } subject { described_class.new(user, tag) }
describe 'destroy_container_image' do describe 'destroy_container_image_tag' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
shared_examples 'matching expected result with protection rules' do shared_examples 'matching expected result with protection rules' do
@ -23,13 +23,13 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
allow(tag).to receive(:protection_rule).and_return(protection_rule) allow(tag).to receive(:protection_rule).and_return(protection_rule)
end end
it { is_expected.to send(expected_result, :destroy_container_image) } it { is_expected.to send(expected_result, :destroy_container_image_tag) }
end end
context 'for admin', :enable_admin_mode do context 'for admin', :enable_admin_mode do
let(:user) { build_stubbed(:admin) } let(:user) { build_stubbed(:admin) }
it { expect_allowed(:destroy_container_image) } it { expect_allowed(:destroy_container_image_tag) }
end end
context 'for owner' do context 'for owner' do
@ -38,7 +38,7 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
end end
context 'when tag has no protection rule' do context 'when tag has no protection rule' do
it { expect_allowed(:destroy_container_image) } it { expect_allowed(:destroy_container_image_tag) }
end end
context 'when tag has protection rule' do context 'when tag has protection rule' do
@ -60,7 +60,7 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
end end
context 'when tag has no protection rule' do context 'when tag has no protection rule' do
it { expect_allowed(:destroy_container_image) } it { expect_allowed(:destroy_container_image_tag) }
end end
context 'when tag has protection rule' do context 'when tag has protection rule' do
@ -82,7 +82,7 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
end end
context 'when tag has no protection rule' do context 'when tag has no protection rule' do
it { expect_allowed(:destroy_container_image) } it { expect_allowed(:destroy_container_image_tag) }
end end
context 'when tag has protection rule' do context 'when tag has protection rule' do

View File

@ -2977,7 +2977,7 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
let(:developer_operations_permissions) do let(:developer_operations_permissions) do
guest_operations_permissions + [ guest_operations_permissions + [
:create_container_image, :update_container_image, :destroy_container_image :create_container_image, :update_container_image, :destroy_container_image, :destroy_container_image_tag
] ]
end end

View File

@ -41,6 +41,10 @@ RSpec.describe RegistrationsController, :with_current_organization, type: :reque
let!(:banned_user) { create(:user, :banned, email: normalized_email) } let!(:banned_user) { create(:user, :banned, email: normalized_email) }
before do
stub_application_setting(enforce_email_subaddress_restrictions: true)
end
it 'renders new action with correct error message', :aggregate_failures do it 'renders new action with correct error message', :aggregate_failures do
request request
@ -48,9 +52,9 @@ RSpec.describe RegistrationsController, :with_current_organization, type: :reque
expect(response).to render_template(:new) expect(response).to render_template(:new)
end end
context 'when feature flag is disabled' do context 'when enforce_email_subaddress_restrictions application setting is disabled' do
before do before do
stub_feature_flags(block_banned_user_normalized_email_reuse: false) stub_application_setting(enforce_email_subaddress_restrictions: false)
end end
it 'does not re-render the form' do it 'does not re-render the form' do

View File

@ -66,7 +66,7 @@ RSpec.shared_context 'ProjectPolicy context' do
create_commit_status create_container_image create_deployment create_commit_status create_container_image create_deployment
create_environment create_merge_request_from create_environment create_merge_request_from
create_pipeline create_release create_pipeline create_release
create_wiki destroy_container_image push_code read_pod_logs create_wiki destroy_container_image destroy_container_image_tag push_code read_pod_logs
read_terraform_state resolve_note update_build cancel_build update_commit_status read_terraform_state resolve_note update_build cancel_build update_commit_status
update_container_image update_deployment update_environment update_container_image update_deployment update_environment
update_merge_request update_pipeline update_release destroy_release update_merge_request update_pipeline update_release destroy_release

View File

@ -11,6 +11,7 @@ RSpec.describe AntiAbuse::BanDuplicateUsersWorker, feature_category: :instance_r
# The banned user cannot be instantiated as banned because validators prevent users from # The banned user cannot be instantiated as banned because validators prevent users from
# being created that have similar characteristics of previously banned users. # being created that have similar characteristics of previously banned users.
before do before do
stub_application_setting(enforce_email_subaddress_restrictions: true)
banned_user.ban! banned_user.ban!
end end
@ -44,9 +45,9 @@ RSpec.describe AntiAbuse::BanDuplicateUsersWorker, feature_category: :instance_r
it_behaves_like 'executing the ban duplicate users worker' it_behaves_like 'executing the ban duplicate users worker'
context 'when the auto_ban_via_detumbled_email feature is disabled' do context 'when the enforce_email_subaddress_restrictions application setting is disabled' do
before do before do
stub_feature_flags(auto_ban_via_detumbled_email: false) stub_application_setting(enforce_email_subaddress_restrictions: false)
end end
it_behaves_like 'does not ban the duplicate user' it_behaves_like 'does not ban the duplicate user'