Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-11-23 06:12:53 +00:00
parent 1f60908cc0
commit 22622fab4a
10 changed files with 615 additions and 143 deletions

View File

@ -19,14 +19,14 @@ the noise (due to constantly failing tests, flaky tests, and so on) so that new
- [ ] [Code review guidelines](https://docs.gitlab.com/ee/development/code_review.html) - [ ] [Code review guidelines](https://docs.gitlab.com/ee/development/code_review.html)
- [ ] [Style guides](https://docs.gitlab.com/ee/development/contributing/style_guides.html) - [ ] [Style guides](https://docs.gitlab.com/ee/development/contributing/style_guides.html)
- [ ] Quarantine test check-list - [ ] Quarantine test check-list
- [ ] Follow the [Quarantining Tests guide](https://about.gitlab.com/handbook/engineering/quality/guidelines/debugging-qa-test-failures/#quarantining-tests). - [ ] Follow the [Quarantining Tests guide](https://about.gitlab.com/handbook/engineering/quality/quality-engineering/debugging-qa-test-failures/#quarantining-tests).
- [ ] Confirm the test has a [`quarantine:` tag with the specified quarantine type](https://about.gitlab.com/handbook/engineering/quality/guidelines/debugging-qa-test-failures/#quarantined-test-types). - [ ] Confirm the test has a [`quarantine:` tag with the specified quarantine type](https://about.gitlab.com/handbook/engineering/quality/quality-engineering/debugging-qa-test-failures/#quarantined-test-types).
- [ ] Note if the test should be [quarantined for a specific environment](https://docs.gitlab.com/ee/development/testing_guide/end_to_end/execution_context_selection.html#quarantine-a-test-for-a-specific-environment). - [ ] Note if the test should be [quarantined for a specific environment](https://docs.gitlab.com/ee/development/testing_guide/end_to_end/execution_context_selection.html#quarantine-a-test-for-a-specific-environment).
- [ ] (Optionally) In case of an emergency (e.g. blocked deployments), consider adding labels to pick into auto-deploy (~"Pick into auto-deploy" ~"priority::1" ~"severity::1"). - [ ] (Optionally) In case of an emergency (e.g. blocked deployments), consider adding labels to pick into auto-deploy (~"Pick into auto-deploy" ~"priority::1" ~"severity::1").
- [ ] Dequarantine test check-list - [ ] Dequarantine test check-list
- [ ] Follow the [Dequarantining Tests guide](https://about.gitlab.com/handbook/engineering/quality/guidelines/debugging-qa-test-failures/#dequarantining-tests). - [ ] Follow the [Dequarantining Tests guide](https://about.gitlab.com/handbook/engineering/quality/quality-engineering/debugging-qa-test-failures/#dequarantining-tests).
- [ ] Confirm the test consistently passes on the target GitLab environment(s). - [ ] Confirm the test consistently passes on the target GitLab environment(s).
- [ ] (Optionally) [Trigger a manual GitLab-QA pipeline](https://about.gitlab.com/handbook/engineering/quality/guidelines/tips-and-tricks/#running-gitlab-qa-pipeline-against-a-specific-gitlab-release) against a specific GitLab environment using the `RELEASE` variable from the `package-and-qa` job of the current merge request. - [ ] (Optionally) [Trigger a manual GitLab-QA pipeline](https://about.gitlab.com/handbook/engineering/quality/quality-engineering/tips-and-tricks/#running-gitlab-qa-pipeline-against-a-specific-gitlab-release) against a specific GitLab environment using the `RELEASE` variable from the `package-and-qa` job of the current merge request.
- [ ] To ensure a faster turnaround, ask in the `#quality` Slack channel for someone to review and merge the merge request, rather than assigning it directly. - [ ] To ensure a faster turnaround, ask in the `#quality` Slack channel for someone to review and merge the merge request, rather than assigning it directly.
<!-- Base labels. --> <!-- Base labels. -->

View File

@ -1,13 +1,28 @@
<script> <script>
import { GlTable, GlButton } from '@gitlab/ui'; import { GlTable, GlButton, GlPagination, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
export default { export default {
name: 'DeployKeysTable', name: 'DeployKeysTable',
i18n: { i18n: {
pageTitle: __('Public deploy keys'), pageTitle: __('Public deploy keys'),
newDeployKeyButtonText: __('New deploy key'), newDeployKeyButtonText: __('New deploy key'),
emptyStateTitle: __('No public deploy keys'),
emptyStateDescription: __(
'Deploy keys grant read/write access to all repositories in your instance',
),
remove: __('Remove deploy key'),
edit: __('Edit deploy key'),
pagination: {
next: __('Next'),
prev: __('Prev'),
},
apiErrorMessage: __('An error occurred fetching the public deploy keys. Please try again.'),
}, },
fields: [ fields: [
{ {
@ -29,13 +44,83 @@ export default {
{ {
key: 'actions', key: 'actions',
label: __('Actions'), label: __('Actions'),
tdClass: 'gl-lg-w-1px gl-white-space-nowrap',
thClass: 'gl-lg-w-1px gl-white-space-nowrap',
}, },
], ],
DEFAULT_PER_PAGE,
components: { components: {
GlTable, GlTable,
GlButton, GlButton,
GlPagination,
TimeAgoTooltip,
GlLoadingIcon,
GlEmptyState,
}, },
inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'], inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'],
data() {
return {
page: 1,
totalItems: 0,
loading: false,
items: [],
};
},
computed: {
shouldShowTable() {
return this.totalItems !== 0 || this.loading;
},
},
watch: {
page(newPage) {
this.fetchDeployKeys(newPage);
},
},
mounted() {
this.fetchDeployKeys();
},
methods: {
editHref(id) {
return this.editPath.replace(':id', id);
},
projectHref(project) {
return `/${cleanLeadingSeparator(project.path_with_namespace)}`;
},
async fetchDeployKeys(page) {
this.loading = true;
try {
const { headers, data: items } = await Api.deployKeys({
page,
public: true,
});
if (this.totalItems === 0) {
this.totalItems = parseInt(headers?.['x-total'], 10) || 0;
}
this.items = items.map(
({ id, title, fingerprint, projects_with_write_access, created_at }) => ({
id,
title,
fingerprint,
projects: projects_with_write_access,
created: created_at,
}),
);
} catch (error) {
createFlash({
message: this.$options.i18n.apiErrorMessage,
captureError: true,
error,
});
this.totalItems = 0;
this.items = [];
}
this.loading = false;
},
},
}; };
</script> </script>
@ -45,10 +130,71 @@ export default {
<h4 class="gl-m-0"> <h4 class="gl-m-0">
{{ $options.i18n.pageTitle }} {{ $options.i18n.pageTitle }}
</h4> </h4>
<gl-button variant="confirm" :href="createPath">{{ <gl-button variant="confirm" :href="createPath" data-testid="new-deploy-key-button">{{
$options.i18n.newDeployKeyButtonText $options.i18n.newDeployKeyButtonText
}}</gl-button> }}</gl-button>
</div> </div>
<gl-table :fields="$options.fields" data-testid="deploy-keys-list" /> <template v-if="shouldShowTable">
<gl-table
:busy="loading"
:items="items"
:fields="$options.fields"
stacked="lg"
data-testid="deploy-keys-list"
>
<template #table-busy>
<gl-loading-icon size="lg" class="gl-my-5" />
</template>
<template #cell(projects)="{ item: { projects } }">
<a
v-for="project in projects"
:key="project.id"
:href="projectHref(project)"
class="gl-display-block"
>{{ project.name_with_namespace }}</a
>
</template>
<template #cell(fingerprint)="{ item: { fingerprint } }">
<code>{{ fingerprint }}</code>
</template>
<template #cell(created)="{ item: { created } }">
<time-ago-tooltip :time="created" />
</template>
<template #head(actions)="{ label }">
<span class="gl-sr-only">{{ label }}</span>
</template>
<template #cell(actions)="{ item: { id } }">
<gl-button
icon="pencil"
:aria-label="$options.i18n.edit"
:href="editHref(id)"
class="gl-mr-2"
/>
<gl-button variant="danger" icon="remove" :aria-label="$options.i18n.remove" />
</template>
</gl-table>
<gl-pagination
v-if="!loading"
v-model="page"
:per-page="$options.DEFAULT_PER_PAGE"
:total-items="totalItems"
:next-text="$options.i18n.pagination.next"
:prev-text="$options.i18n.pagination.prev"
align="center"
/>
</template>
<gl-empty-state
v-else
:svg-path="emptyStateSvgPath"
:title="$options.i18n.emptyStateTitle"
:description="$options.i18n.emptyStateDescription"
:primary-button-text="$options.i18n.newDeployKeyButtonText"
:primary-button-link="createPath"
/>
</div> </div>
</template> </template>

View File

@ -91,6 +91,7 @@ const Api = {
projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings', projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings',
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings', groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings', notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
group(groupId, callback = () => {}) { group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@ -950,6 +951,12 @@ const Api = {
return axios.delete(url); return axios.delete(url);
}, },
deployKeys(params = {}) {
const url = Api.buildUrl(this.deployKeysPath);
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } });
},
async updateNotificationSettings(projectId, groupId, data = {}) { async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath); let url = Api.buildUrl(this.notificationSettingsPath);

View File

@ -388,7 +388,7 @@ export default {
<div <div
v-if="showMultiLineComment" v-if="showMultiLineComment"
data-testid="multiline-comment" data-testid="multiline-comment"
class="gl-mb-3 gl-text-gray-500 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" class="gl-mb-5 gl-text-gray-500 gl-border-gray-100 gl-border-b-solid gl-border-b-1 gl-pb-4"
> >
<gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')">
<template #startLine> <template #startLine>

View File

@ -50,6 +50,109 @@ the pipeline that finishes later creates the job artifact.
For more examples, view the [keyword reference for the `.gitlab-ci.yml` file](../yaml/index.md#artifacts). For more examples, view the [keyword reference for the `.gitlab-ci.yml` file](../yaml/index.md#artifacts).
### Use CI/CD variables to define the artifacts name
You can use [CI/CD variables](../variables/index.md) to dynamically define the
artifacts file's name.
For example, to create an archive with a name of the current job:
```yaml
job:
artifacts:
name: "$CI_JOB_NAME"
paths:
- binaries/
```
To create an archive with a name of the current branch or tag including only
the binaries directory:
```yaml
job:
artifacts:
name: "$CI_COMMIT_REF_NAME"
paths:
- binaries/
```
If your branch-name contains forward slashes
(for example `feature/my-feature`) it's advised to use `$CI_COMMIT_REF_SLUG`
instead of `$CI_COMMIT_REF_NAME` for proper naming of the artifact.
To create an archive with a name of the current job and the current branch or
tag including only the binaries directory:
```yaml
job:
artifacts:
name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
paths:
- binaries/
```
To create an archive with a name of the current [stage](../yaml/index.md#stages) and branch name:
```yaml
job:
artifacts:
name: "$CI_JOB_STAGE-$CI_COMMIT_REF_NAME"
paths:
- binaries/
```
If you use **Windows Batch** to run your shell scripts you must replace
`$` with `%`:
```yaml
job:
artifacts:
name: "%CI_JOB_STAGE%-%CI_COMMIT_REF_NAME%"
paths:
- binaries/
```
If you use **Windows PowerShell** to run your shell scripts you must replace
`$` with `$env:`:
```yaml
job:
artifacts:
name: "$env:CI_JOB_STAGE-$env:CI_COMMIT_REF_NAME"
paths:
- binaries/
```
### Exclude files from job artifacts
Use [`artifacts:exclude`](../yaml/index.md#artifactsexclude) to prevent files from
being added to an artifacts archive.
For example, to store all files in `binaries/`, but not `*.o` files located in
subdirectories of `binaries/`.
```yaml
artifacts:
paths:
- binaries/
exclude:
- binaries/**/*.o
```
Unlike [`artifacts:paths`](../yaml/index.md#artifactspaths), `exclude` paths are not recursive.
To exclude all of the contents of a directory, match them explicitly rather
than matching the directory itself.
For example, to store all files in `binaries/` but nothing located in the `temp/` subdirectory:
```yaml
artifacts:
paths:
- binaries/
exclude:
- binaries/temp/**/*
```
## Download job artifacts ## Download job artifacts
You can download job artifacts or view the job archive: You can download job artifacts or view the job archive:
@ -103,6 +206,35 @@ To delete a job:
1. On the top right of the job's log, select **Erase job log** (**{remove}**). 1. On the top right of the job's log, select **Erase job log** (**{remove}**).
1. On the confirmation dialog, select **OK**. 1. On the confirmation dialog, select **OK**.
## Expose job artifacts in the merge request UI
Use the [`artifacts:expose_as`](../yaml/index.md#artifactsexpose_as) keyword to expose
[job artifacts](../pipelines/job_artifacts.md) in the [merge request](../../user/project/merge_requests/index.md) UI.
For example, to match a single file:
```yaml
test:
script: ["echo 'test' > file.txt"]
artifacts:
expose_as: 'artifact 1'
paths: ['file.txt']
```
With this configuration, GitLab adds a link **artifact 1** to the relevant merge request
that points to `file1.txt`. To access the link, select **View exposed artifact**
below the pipeline graph in the merge request overview.
An example that matches an entire directory:
```yaml
test:
script: ["mkdir test && echo 'test' > test/file.txt"]
artifacts:
expose_as: 'artifact 1'
paths: ['test/']
```
## Retrieve job artifacts for other projects ## Retrieve job artifacts for other projects
To retrieve a job artifact from a different project, you might need to use a To retrieve a job artifact from a different project, you might need to use a

View File

@ -628,12 +628,13 @@ test_job_2:
### `artifacts` ### `artifacts`
Use `artifacts` to specify a list of files and directories that are Use `artifacts` to specify which files to save as [job artifacts](../pipelines/job_artifacts.md).
Job artifacts are a list of files and directories that are
attached to the job when it [succeeds, fails, or always](#artifactswhen). attached to the job when it [succeeds, fails, or always](#artifactswhen).
The artifacts are sent to GitLab after the job finishes. They are The artifacts are sent to GitLab after the job finishes. They are
available for download in the GitLab UI if the size is not available for download in the GitLab UI if the size is smaller than the
larger than the [maximum artifact size](../../user/gitlab_com/index.md#gitlab-cicd). the [maximum artifact size](../../user/gitlab_com/index.md#gitlab-cicd).
By default, jobs in later stages automatically download all the artifacts created By default, jobs in later stages automatically download all the artifacts created
by jobs in earlier stages. You can control artifact download behavior in jobs with by jobs in earlier stages. You can control artifact download behavior in jobs with
@ -652,16 +653,18 @@ artifacts are restored after [caches](#cache).
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15122) in GitLab 13.1 > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15122) in GitLab 13.1
> - Requires GitLab Runner 13.1 > - Requires GitLab Runner 13.1
`exclude` makes it possible to prevent files from being added to an artifacts Use `artifacts:exclude` to prevent files from being added to an artifacts archive.
archive.
Similar to [`artifacts:paths`](#artifactspaths), `exclude` paths are relative **Keyword type**: Job keyword. You can use it only as part of a job or in the
to the project directory. You can use Wildcards that use [`default:` section](#default).
[glob](https://en.wikipedia.org/wiki/Glob_(programming)) or
[`doublestar.PathMatch`](https://pkg.go.dev/github.com/bmatcuk/doublestar@v1.2.2?tab=doc#PathMatch) patterns.
For example, to store all files in `binaries/`, but not `*.o` files located in **Possible inputs**:
subdirectories of `binaries/`:
- An array of file paths, relative to the project directory.
- You can use Wildcards that use [glob](https://en.wikipedia.org/wiki/Glob_(programming)) or
[`doublestar.PathMatch`](https://pkg.go.dev/github.com/bmatcuk/doublestar@v1.2.2?tab=doc#PathMatch) patterns.
**Example of `artifacts:exclude`**:
```yaml ```yaml
artifacts: artifacts:
@ -671,20 +674,18 @@ artifacts:
- binaries/**/*.o - binaries/**/*.o
``` ```
Unlike [`artifacts:paths`](#artifactspaths), `exclude` paths are not recursive. To exclude all of the contents of a directory, you can match them explicitly rather than matching the directory itself. This example stores all files in `binaries/`, but not `*.o` files located in
subdirectories of `binaries/`.
For example, to store all files in `binaries/` but nothing located in the `temp/` subdirectory: **Additional details**:
```yaml - `artifacts:exclude` paths are not searched recursively.
artifacts: - Files matched by [`artifacts:untracked`](#artifactsuntracked) can be excluded using
paths: `artifacts:exclude` too.
- binaries/
exclude:
- binaries/temp/**/*
```
Files matched by [`artifacts:untracked`](#artifactsuntracked) can be excluded using **Related topics**:
`artifacts:exclude` too.
- [Exclude files from job artifacts](../pipelines/job_artifacts.md#exclude-files-from-job-artifacts)
#### `artifacts:expire_in` #### `artifacts:expire_in`
@ -704,8 +705,14 @@ they expire and are deleted. The `expire_in` setting does not affect:
pipeline artifacts. See [When pipeline artifacts are deleted](../pipelines/pipeline_artifacts.md#when-pipeline-artifacts-are-deleted) pipeline artifacts. See [When pipeline artifacts are deleted](../pipelines/pipeline_artifacts.md#when-pipeline-artifacts-are-deleted)
for more information. for more information.
The value of `expire_in` is an elapsed time in seconds, unless a unit is provided. Valid values After their expiry, artifacts are deleted hourly by default (using a cron job), and are not
include: accessible anymore.
**Keyword type**: Job keyword. You can use it only as part of a job or in the
[`default:` section](#default).
**Possible inputs**: The expiry time. If no unit is provided, the time is in seconds.
Valid values include:
- `'42'` - `'42'`
- `42 seconds` - `42 seconds`
@ -717,7 +724,7 @@ include:
- `3 weeks and 2 days` - `3 weeks and 2 days`
- `never` - `never`
To expire artifacts one week after being uploaded: **Example of `artifacts:expire_in`**:
```yaml ```yaml
job: job:
@ -725,28 +732,31 @@ job:
expire_in: 1 week expire_in: 1 week
``` ```
The expiration time period begins when the artifact is uploaded and stored on GitLab. If the expiry **Additional details**:
time is not defined, it defaults to the
[instance wide setting](../../user/admin_area/settings/continuous_integration.md#default-artifacts-expiration)
(30 days by default).
To override the expiration date and protect artifacts from being automatically deleted: - The expiration time period begins when the artifact is uploaded and stored on GitLab.
If the expiry time is not defined, it defaults to the [instance wide setting](../../user/admin_area/settings/continuous_integration.md#default-artifacts-expiration).
- Select **Keep** on the job page. - To override the expiration date and protect artifacts from being automatically deleted:
- [In GitLab 13.3 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/22761), set the value of - Select **Keep** on the job page.
`expire_in` to `never`. - [In GitLab 13.3 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/22761), set the value of
`expire_in` to `never`.
After their expiry, artifacts are deleted hourly by default (using a cron job), and are not
accessible anymore.
#### `artifacts:expose_as` #### `artifacts:expose_as`
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15018) in GitLab 12.5. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15018) in GitLab 12.5.
Use the `expose_as` keyword to expose [job artifacts](../pipelines/job_artifacts.md) Use the `artifacts:expose_as` keyword to
in the [merge request](../../user/project/merge_requests/index.md) UI. [expose job artifacts in the merge request UI](../pipelines/job_artifacts.md#expose-job-artifacts-in-the-merge-request-ui).
For example, to match a single file: **Keyword type**: Job keyword. You can use it only as part of a job or in the
[`default:` section](#default).
**Possible inputs**:
- The name to display in the merge request UI for the artifacts download link.
Must be combined with [`artifacts:paths`](#artifactspaths).
**Example of `artifacts:expose_as`**:
```yaml ```yaml
test: test:
@ -756,108 +766,50 @@ test:
paths: ['file.txt'] paths: ['file.txt']
``` ```
With this configuration, GitLab adds a link **artifact 1** to the relevant merge request **Additional details**:
that points to `file1.txt`. To access the link, select **View exposed artifact**
below the pipeline graph in the merge request overview.
An example that matches an entire directory: - If `artifacts:paths` uses [CI/CD variables](../variables/index.md), the artifacts do not display in the UI.
```yaml
test:
script: ["mkdir test && echo 'test' > test/file.txt"]
artifacts:
expose_as: 'artifact 1'
paths: ['test/']
```
Note the following:
- Artifacts do not display in the merge request UI when using variables to define the `artifacts:paths`.
- A maximum of 10 job artifacts per merge request can be exposed. - A maximum of 10 job artifacts per merge request can be exposed.
- Glob patterns are unsupported. - Glob patterns are unsupported.
- If a directory is specified, the link is to the job [artifacts browser](../pipelines/job_artifacts.md#download-job-artifacts) if there is more than - If a directory is specified and there is more than one file in the directory,
one file in the directory. the link is to the job [artifacts browser](../pipelines/job_artifacts.md#download-job-artifacts).
- For exposed single file artifacts with `.html`, `.htm`, `.txt`, `.json`, `.xml`, - If [GitLab Pages](../../administration/pages/index.md) is enabled, GitLab automatically
and `.log` extensions, if [GitLab Pages](../../administration/pages/index.md) is: renders the artifacts when the artifacts is a single file with one of these extensions:
- Enabled, GitLab automatically renders the artifact. - `.html` or `.htm`
- Not enabled, the file is displayed in the artifacts browser. - `.txt`
- `.json`
- `.xml`
- `.log`
#### `artifacts:name` #### `artifacts:name`
Use the `name` directive to define the name of the created artifacts Use the `artifacts:name` keyword to define the name of the created artifacts
archive. You can specify a unique name for every archive. The `artifacts:name` archive. You can specify a unique name for every archive.
variable can make use of any of the [predefined variables](../variables/index.md).
The default name is `artifacts`, which becomes `artifacts.zip` when you download it. If not defined, the default name is `artifacts`, which becomes `artifacts.zip` when downloaded.
**Keyword type**: Job keyword. You can use it only as part of a job or in the
[`default:` section](#default).
**Possible inputs**:
- The name of the artifacts archive. Can use [CI/CD variables](../variables/index.md).
**Example of `artifacts:name`**:
To create an archive with a name of the current job: To create an archive with a name of the current job:
```yaml ```yaml
job: job:
artifacts: artifacts:
name: "$CI_JOB_NAME" name: "job1-artifacts-file"
paths: paths:
- binaries/ - binaries/
``` ```
To create an archive with a name of the current branch or tag including only **Related topics**:
the binaries directory:
```yaml - [Use CI/CD variables to define the artifacts name.](../pipelines/job_artifacts.md#use-cicd-variables-to-define-the-artifacts-name)
job:
artifacts:
name: "$CI_COMMIT_REF_NAME"
paths:
- binaries/
```
If your branch-name contains forward slashes
(for example `feature/my-feature`) it's advised to use `$CI_COMMIT_REF_SLUG`
instead of `$CI_COMMIT_REF_NAME` for proper naming of the artifact.
To create an archive with a name of the current job and the current branch or
tag including only the binaries directory:
```yaml
job:
artifacts:
name: "$CI_JOB_NAME-$CI_COMMIT_REF_NAME"
paths:
- binaries/
```
To create an archive with a name of the current [stage](#stages) and branch name:
```yaml
job:
artifacts:
name: "$CI_JOB_STAGE-$CI_COMMIT_REF_NAME"
paths:
- binaries/
```
---
If you use **Windows Batch** to run your shell scripts you must replace
`$` with `%`:
```yaml
job:
artifacts:
name: "%CI_JOB_STAGE%-%CI_COMMIT_REF_NAME%"
paths:
- binaries/
```
If you use **Windows PowerShell** to run your shell scripts you must replace
`$` with `$env:`:
```yaml
job:
artifacts:
name: "$env:CI_JOB_STAGE-$env:CI_COMMIT_REF_NAME"
paths:
- binaries/
```
#### `artifacts:paths` #### `artifacts:paths`

View File

@ -3576,6 +3576,9 @@ msgstr ""
msgid "An error occurred fetching the project authors." msgid "An error occurred fetching the project authors."
msgstr "" msgstr ""
msgid "An error occurred fetching the public deploy keys. Please try again."
msgstr ""
msgid "An error occurred previewing the blob" msgid "An error occurred previewing the blob"
msgstr "" msgstr ""
@ -23536,6 +23539,9 @@ msgstr ""
msgid "No projects found" msgid "No projects found"
msgstr "" msgstr ""
msgid "No public deploy keys"
msgstr ""
msgid "No public groups" msgid "No public groups"
msgstr "" msgstr ""

View File

@ -1,8 +1,17 @@
import { merge } from 'lodash'; import { merge } from 'lodash';
import { GlTable, GlButton } from '@gitlab/ui'; import { GlLoadingIcon, GlEmptyState, GlPagination } from '@gitlab/ui';
import { nextTick } from 'vue';
import responseBody from 'test_fixtures/api/deploy_keys/index.json';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DeployKeysTable from '~/admin/deploy_keys/components/table.vue'; import DeployKeysTable from '~/admin/deploy_keys/components/table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import createFlash from '~/flash';
jest.mock('~/api');
jest.mock('~/flash');
describe('DeployKeysTable', () => { describe('DeployKeysTable', () => {
let wrapper; let wrapper;
@ -14,12 +23,53 @@ describe('DeployKeysTable', () => {
emptyStateSvgPath: '/assets/illustrations/empty-state/empty-deploy-keys.svg', emptyStateSvgPath: '/assets/illustrations/empty-state/empty-deploy-keys.svg',
}; };
const deployKey = responseBody[0];
const deployKey2 = responseBody[1];
const createComponent = (provide = {}) => { const createComponent = (provide = {}) => {
wrapper = mountExtended(DeployKeysTable, { wrapper = mountExtended(DeployKeysTable, {
provide: merge({}, defaultProvide, provide), provide: merge({}, defaultProvide, provide),
}); });
}; };
const findEditButton = (index) =>
wrapper.findAllByLabelText(DeployKeysTable.i18n.edit, { selector: 'a' }).at(index);
const findRemoveButton = (index) =>
wrapper.findAllByLabelText(DeployKeysTable.i18n.remove, { selector: 'button' }).at(index);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findTimeAgoTooltip = (index) => wrapper.findAllComponents(TimeAgoTooltip).at(index);
const findPagination = () => wrapper.findComponent(GlPagination);
const expectDeployKeyIsRendered = (expectedDeployKey, expectedRowIndex) => {
const editButton = findEditButton(expectedRowIndex);
const timeAgoTooltip = findTimeAgoTooltip(expectedRowIndex);
expect(wrapper.findByText(expectedDeployKey.title).exists()).toBe(true);
expect(wrapper.findByText(expectedDeployKey.fingerprint, { selector: 'code' }).exists()).toBe(
true,
);
expect(timeAgoTooltip.exists()).toBe(true);
expect(timeAgoTooltip.props('time')).toBe(expectedDeployKey.created_at);
expect(editButton.exists()).toBe(true);
expect(editButton.attributes('href')).toBe(`/admin/deploy_keys/${expectedDeployKey.id}/edit`);
expect(findRemoveButton(expectedRowIndex).exists()).toBe(true);
};
const itRendersTheEmptyState = () => {
it('renders empty state', () => {
const emptyState = wrapper.findComponent(GlEmptyState);
expect(emptyState.exists()).toBe(true);
expect(emptyState.props()).toMatchObject({
svgPath: defaultProvide.emptyStateSvgPath,
title: DeployKeysTable.i18n.emptyStateTitle,
description: DeployKeysTable.i18n.emptyStateDescription,
primaryButtonText: DeployKeysTable.i18n.newDeployKeyButtonText,
primaryButtonLink: defaultProvide.createPath,
});
});
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
@ -30,18 +80,128 @@ describe('DeployKeysTable', () => {
expect(wrapper.findByText(DeployKeysTable.i18n.pageTitle).exists()).toBe(true); expect(wrapper.findByText(DeployKeysTable.i18n.pageTitle).exists()).toBe(true);
}); });
it('renders table', () => {
createComponent();
expect(wrapper.findComponent(GlTable).exists()).toBe(true);
});
it('renders `New deploy key` button', () => { it('renders `New deploy key` button', () => {
createComponent(); createComponent();
const newDeployKeyButton = wrapper.findComponent(GlButton); const newDeployKeyButton = wrapper.findByTestId('new-deploy-key-button');
expect(newDeployKeyButton.text()).toBe(DeployKeysTable.i18n.newDeployKeyButtonText); expect(newDeployKeyButton.exists()).toBe(true);
expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath); expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath);
}); });
describe('when `/deploy_keys` API request is pending', () => {
beforeEach(() => {
Api.deployKeys.mockImplementation(() => new Promise(() => {}));
});
it('shows loading icon', async () => {
createComponent();
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('when `/deploy_keys` API request is successful', () => {
describe('when there are deploy keys', () => {
beforeEach(() => {
Api.deployKeys.mockResolvedValue({
data: responseBody,
headers: { 'x-total': `${responseBody.length}` },
});
createComponent();
});
it('renders deploy keys in table', () => {
expectDeployKeyIsRendered(deployKey, 0);
expectDeployKeyIsRendered(deployKey2, 1);
});
});
describe('pagination', () => {
beforeEach(() => {
Api.deployKeys.mockResolvedValueOnce({
data: [deployKey],
headers: { 'x-total': '2' },
});
createComponent();
});
it('renders pagination', () => {
const pagination = findPagination();
expect(pagination.exists()).toBe(true);
expect(pagination.props()).toMatchObject({
value: 1,
perPage: DEFAULT_PER_PAGE,
totalItems: responseBody.length,
nextText: DeployKeysTable.i18n.pagination.next,
prevText: DeployKeysTable.i18n.pagination.prev,
align: 'center',
});
});
describe('when pagination is changed', () => {
it('calls API with `page` parameter', async () => {
const pagination = findPagination();
expectDeployKeyIsRendered(deployKey, 0);
Api.deployKeys.mockResolvedValue({
data: [deployKey2],
headers: { 'x-total': '2' },
});
pagination.vm.$emit('input', 2);
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
expect(pagination.exists()).toBe(false);
await waitForPromises();
expect(Api.deployKeys).toHaveBeenCalledWith({
page: 2,
public: true,
});
expectDeployKeyIsRendered(deployKey2, 0);
});
});
});
describe('when there are no deploy keys', () => {
beforeEach(() => {
Api.deployKeys.mockResolvedValue({
data: [],
headers: { 'x-total': '0' },
});
createComponent();
});
itRendersTheEmptyState();
});
});
describe('when `deploy_keys` API request is unsuccessful', () => {
const error = new Error('Network Error');
beforeEach(() => {
Api.deployKeys.mockRejectedValue(error);
createComponent();
});
itRendersTheEmptyState();
it('displays flash', () => {
expect(createFlash).toHaveBeenCalledWith({
message: DeployKeysTable.i18n.apiErrorMessage,
captureError: true,
error,
});
});
});
}); });

View File

@ -1,5 +1,5 @@
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Api from '~/api'; import Api, { DEFAULT_PER_PAGE } from '~/api';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
@ -1574,6 +1574,51 @@ describe('Api', () => {
}); });
}); });
describe('deployKeys', () => {
it('fetches deploy keys', async () => {
const deployKeys = [
{
id: 7,
title: 'My title 1',
created_at: '2021-10-29T16:59:55.229Z',
expires_at: null,
key:
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDLvQzRX960N7dxPdge9o5a96+M4GEGQ7rxT2D3wAQDtQFjQV5ZcKb5wfeLtYLe3kRVI4lCO10PXeQppb1XBaYmVO31IaRkcgmMEPVyfp76Dp4CJZz6aMEbbcqfaHkDre0Fa8kzTXnBJVh2NeDbBfGMjFM5NRQLhKykodNsepO6dQ== dummy@gitlab.com',
fingerprint: '81:93:63:b9:1e:24:a2:aa:e0:87:d3:3f:42:81:f2:c2',
projects_with_write_access: [
{
id: 11,
description: null,
name: 'project1',
name_with_namespace: 'John Doe3 / project1',
path: 'project1',
path_with_namespace: 'namespace1/project1',
created_at: '2021-10-29T16:59:54.668Z',
},
{
id: 12,
description: null,
name: 'project2',
name_with_namespace: 'John Doe4 / project2',
path: 'project2',
path_with_namespace: 'namespace2/project2',
created_at: '2021-10-29T16:59:55.116Z',
},
],
},
];
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/deploy_keys`;
mock.onGet(expectedUrl).reply(httpStatus.OK, deployKeys);
const params = { page: 2, public: true };
const { data } = await Api.deployKeys(params);
expect(data).toEqual(deployKeys);
expect(mock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE });
});
});
describe('Feature Flag User List', () => { describe('Feature Flag User List', () => {
let expectedUrl; let expectedUrl;
let projectId; let projectId;

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::DeployKeys, '(JavaScript fixtures)', type: :request do
include ApiHelpers
include JavaScriptFixturesHelpers
let_it_be(:admin) { create(:admin) }
let_it_be(:project) { create(:project) }
let_it_be(:project2) { create(:project) }
let_it_be(:deploy_key) { create(:deploy_key, public: true) }
let_it_be(:deploy_key2) { create(:deploy_key, public: true) }
let_it_be(:deploy_keys_project) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key) }
let_it_be(:deploy_keys_project2) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key) }
let_it_be(:deploy_keys_project3) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key2) }
let_it_be(:deploy_keys_project4) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key2) }
it 'api/deploy_keys/index.json' do
get api("/deploy_keys", admin)
expect(response).to be_successful
end
end