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

View File

@ -1,16 +1,68 @@
<script>
import { GlLoadingIcon, GlFormGroup } from '@gitlab/ui';
import { fetchPolicies } from '~/lib/graphql';
import {
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 { 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 VariableValuesListbox from './variable_values_listbox.vue';
let pollTimeout;
export const POLLING_INTERVAL = 2000;
export default {
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: {
GlLoadingIcon,
GlIcon,
GlButton,
GlCollapsibleListbox,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlLink,
GlLoadingIcon,
GlSprintf,
VariableValuesListbox,
},
props: {
defaultBranch: {
type: String,
required: true,
},
fileParams: {
type: Object,
required: false,
default: () => ({}),
},
isMaintainer: {
type: Boolean,
required: true,
},
projectPath: {
type: String,
required: true,
@ -19,16 +71,26 @@ export default {
type: String,
required: true,
},
defaultBranch: {
settingsLink: {
type: String,
required: true,
},
variableParams: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
ciConfigVariables: null,
configVariablesWithDescription: {},
form: {},
refValue: {
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,
},
};
@ -37,6 +99,9 @@ export default {
ciConfigVariables: {
fetchPolicy: fetchPolicies.NO_CACHE,
query: ciConfigVariablesQuery,
skip() {
return Object.keys(this.form).includes(this.refFullName);
},
variables() {
return {
fullPath: this.projectPath,
@ -46,21 +111,183 @@ export default {
update({ project }) {
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) {
reportToSentry(this.$options.name, error);
},
pollInterval: POLLING_INTERVAL,
},
},
computed: {
descriptions() {
return this.form[this.refFullName]?.descriptions ?? {};
},
isFetchingCiConfigVariables() {
return this.ciConfigVariables === null;
},
isLoading() {
return this.$apollo.queries.ciConfigVariables.loading || this.isFetchingCiConfigVariables;
},
isMobile() {
return ['sm', 'xs'].includes(GlBreakpointInstance.getBreakpointSize());
},
refFullName() {
return this.refValue.fullName;
},
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>
<div>
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="md" />
<gl-form-group v-else>
<pre>{{ ciConfigVariables }}</pre>
<gl-form-group v-else :label="s__('Pipeline|Variables')">
<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>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,10 @@ class LfsFileLock < ApplicationRecord
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)
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) }
rule { protected_for_delete }.policy do
prevent :destroy_container_image
prevent :destroy_container_image_tag
end
end
end

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ module Projects
@container_repository = container_repository
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
@tag_names = params[:tags]

View File

@ -6,6 +6,7 @@ module AntiAbuse
def validate(record)
return if record.errors.include?(:email)
return unless ::Gitlab::CurrentSettings.enforce_email_subaddress_restrictions
email = record.email
@ -24,14 +25,10 @@ module AntiAbuse
private
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?
end
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
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.number_field :pages_extra_deployments_default_expiry_seconds, class: 'form-control gl-form-input'
.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))
%h5
= s_("AdminSettings|Configure Let's Encrypt")

View File

@ -22,7 +22,7 @@ module AntiAbuse
attr_reader :banned_user
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"

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
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/161804
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/516169
milestone: '17.9'
milestone: '17.10'
group: group::environments
type: wip
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
- - cluster_agent
- 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
- 1
- - clusters_agents_auto_flow_work_items_created_event

View File

@ -23182,7 +23182,7 @@ A tag from a container repository.
| 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`

View File

@ -250,7 +250,7 @@ If you have Kubernetes clusters connected with GitLab, [upgrade your GitLab agen
### Elasticsearch
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
[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 >}}
## Check for pending advanced search migrations
## Advanced search migrations
### Check for pending migrations
{{< details >}}
@ -511,3 +513,15 @@ sudo -u git -H bundle exec rake gitlab:elastic:list_pending_migrations
{{< /tab >}}
{{< /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
| 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 ) |
| Maximum payload size | 25 MB |
| Timeout | 10 seconds |
| [Multiple Pages deployments](../project/pages/_index.md#limits) | 100 extra deployments (Premium tier), 500 extra deployments (Ultimate tier) |
| 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 ) |
| Maximum payload size | 25 MB |
| Timeout | 10 seconds |
| [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:

View File

@ -104,7 +104,7 @@ To improve your security, try these features:
| 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 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 |
| [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 |

View File

@ -205,13 +205,6 @@ Prerequisites:
## Expiring deployments
{{< details >}}
- Tier: Premium, Ultimate
- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
{{< /details >}}
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/162826) in GitLab 17.4.
@ -233,10 +226,6 @@ deploy-pages:
- 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.
Stopped deployments are subsequently deleted by another cron job that also
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.
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
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
[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
{{< history >}}
@ -493,3 +314,8 @@ deployment is triggered:
pages:
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'
end
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
.new(repository.project, current_user, tags: [declared_params[:tag_name]])
@ -205,8 +205,8 @@ module API
authorize! :read_container_image, repository
end
def authorize_destroy_container_image!
authorize! :destroy_container_image, repository
def authorize_destroy_container_image_tag!
authorize! :destroy_container_image_tag, tag
end
def authorize_admin_container_image!

View File

@ -82,6 +82,10 @@ RSpec.describe Projects::Registry::TagsController do
end
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
before do
project.add_developer(user)
@ -96,12 +100,16 @@ RSpec.describe Projects::Registry::TagsController do
expect_delete_tags(%w[rc1])
destroy_tag('rc1')
expect(controller).to have_received(:authorize_destroy_container_image_tag!)
end
it 'makes it possible to delete a tag that ends with a dot' do
expect_delete_tags(%w[test.])
destroy_tag('test.')
expect(controller).to have_received(:authorize_destroy_container_image_tag!)
end
it 'tracks the event', :snowplow do
@ -114,6 +122,21 @@ RSpec.describe Projects::Registry::TagsController do
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
def destroy_tag(name)

View File

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

View File

@ -1,17 +1,24 @@
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 Vue from 'vue';
import { fetchPolicies } from '~/lib/graphql';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { fetchPolicies } from '~/lib/graphql';
import { reportToSentry } from '~/ci/utils';
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';
Vue.use(VueApollo);
jest.mock('~/ci/utils');
jest.mock('@gitlab/ui/dist/utils', () => ({
GlBreakpointInstance: {
getBreakpointSize: jest.fn(),
},
}));
describe('PipelineVariablesForm', () => {
let wrapper;
@ -19,16 +26,41 @@ describe('PipelineVariablesForm', () => {
let mockCiConfigVariables;
const defaultProps = {
projectPath: 'group/project',
defaultBranch: 'main',
isMaintainer: true,
projectPath: 'group/project',
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]];
mockApollo = createMockApollo(handlers);
wrapper = shallowMount(PipelineVariablesForm, {
wrapper = shallowMountExtended(PipelineVariablesForm, {
apolloProvider: mockApollo,
propsData: { ...defaultProps, ...props },
});
@ -36,8 +68,11 @@ describe('PipelineVariablesForm', () => {
await waitForPromises();
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
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(() => {
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', () => {
it('has correct apollo query configuration', async () => {
await createComponent();
const { apollo } = wrapper.vm.$options;
expect(apollo.ciConfigVariables).toMatchObject({
fetchPolicy: fetchPolicies.NO_CACHE,
query: ciConfigVariablesQuery,
});
expect(apollo.ciConfigVariables.fetchPolicy).toBe(fetchPolicies.NO_CACHE);
expect(apollo.ciConfigVariables.query).toBe(ciConfigVariablesQuery);
});
it('makes query with correct variables', async () => {
@ -86,10 +153,73 @@ describe('PipelineVariablesForm', () => {
});
it('reports to sentry when query fails', async () => {
mockCiConfigVariables = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const error = new Error('GraphQL error');
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 { 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';
describe('Project Setting Row', () => {
let wrapper;
const findLabel = () => wrapper.findByTestId('project-settings-row-label');
const findHelpText = () => wrapper.findByTestId('project-settings-row-help-text');
const createComponent = (customProps = {}) => {
const propsData = { ...customProps };
return shallowMount(projectSettingRow, { propsData });
return shallowMountExtended(projectSettingRow, {
propsData: {
...customProps,
},
stubs: { GlFormGroup },
});
};
beforeEach(() => {
@ -19,25 +26,26 @@ describe('Project Setting Row', () => {
wrapper = createComponent({ label: 'Test label' });
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', () => {
expect(wrapper.find('label').exists()).toBe(false);
expect(findLabel().exists()).toBe(false);
});
it('should apply gl-text-disabled class to label when locked', async () => {
wrapper = createComponent({ label: 'Test label', locked: true });
await nextTick();
expect(wrapper.find('label').classes()).toContain('gl-text-disabled');
expect(findLabel().classes()).toContain('gl-text-disabled');
});
it('should render default slot content', () => {
wrapper = shallowMount(projectSettingRow, {
wrapper = shallowMountExtended(projectSettingRow, {
slots: {
'label-icon': GlIcon,
},
stubs: { GlFormGroup },
});
expect(wrapper.findComponent(GlIcon).exists()).toBe(true);
});
@ -63,10 +71,10 @@ describe('Project Setting Row', () => {
wrapper = createComponent({ helpText: 'Test text' });
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', () => {
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',
helpText: 'Use AI-powered features in this project.',
label: 'GitLab Duo',
labelFor: null,
locked: false,
});
});
@ -985,6 +986,7 @@ describe('Settings Panel', () => {
helpPath: '/help/user/duo_amazon_q/_index.md',
helpText: 'This project can use Amazon Q.',
label: 'Amazon Q',
labelFor: null,
locked: false,
});
});

View File

@ -11,7 +11,7 @@ RSpec.describe Mutations::ContainerRepositories::DestroyTags, feature_category:
let(:id) { repository.to_global_id }
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
let(:tags) { %w[A C D E] }

View File

@ -2,7 +2,7 @@
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) }
subject { lfs_file_lock }
@ -57,4 +57,18 @@ RSpec.describe LfsFileLock do
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

View File

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

View File

@ -11,7 +11,7 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
subject { described_class.new(user, tag) }
describe 'destroy_container_image' do
describe 'destroy_container_image_tag' do
using RSpec::Parameterized::TableSyntax
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)
end
it { is_expected.to send(expected_result, :destroy_container_image) }
it { is_expected.to send(expected_result, :destroy_container_image_tag) }
end
context 'for admin', :enable_admin_mode do
let(:user) { build_stubbed(:admin) }
it { expect_allowed(:destroy_container_image) }
it { expect_allowed(:destroy_container_image_tag) }
end
context 'for owner' do
@ -38,7 +38,7 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
end
context 'when tag has no protection rule' do
it { expect_allowed(:destroy_container_image) }
it { expect_allowed(:destroy_container_image_tag) }
end
context 'when tag has protection rule' do
@ -60,7 +60,7 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
end
context 'when tag has no protection rule' do
it { expect_allowed(:destroy_container_image) }
it { expect_allowed(:destroy_container_image_tag) }
end
context 'when tag has protection rule' do
@ -82,7 +82,7 @@ RSpec.describe ContainerRegistry::TagPolicy, feature_category: :container_regist
end
context 'when tag has no protection rule' do
it { expect_allowed(:destroy_container_image) }
it { expect_allowed(:destroy_container_image_tag) }
end
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
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

View File

@ -41,6 +41,10 @@ RSpec.describe RegistrationsController, :with_current_organization, type: :reque
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
request
@ -48,9 +52,9 @@ RSpec.describe RegistrationsController, :with_current_organization, type: :reque
expect(response).to render_template(:new)
end
context 'when feature flag is disabled' do
context 'when enforce_email_subaddress_restrictions application setting is disabled' do
before do
stub_feature_flags(block_banned_user_normalized_email_reuse: false)
stub_application_setting(enforce_email_subaddress_restrictions: false)
end
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_environment create_merge_request_from
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
update_container_image update_deployment update_environment
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
# being created that have similar characteristics of previously banned users.
before do
stub_application_setting(enforce_email_subaddress_restrictions: true)
banned_user.ban!
end
@ -44,9 +45,9 @@ RSpec.describe AntiAbuse::BanDuplicateUsersWorker, feature_category: :instance_r
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
stub_feature_flags(auto_ban_via_detumbled_email: false)
stub_application_setting(enforce_email_subaddress_restrictions: false)
end
it_behaves_like 'does not ban the duplicate user'