diff --git a/app/controllers/crate/settings/new-trusted-publisher.js b/app/controllers/crate/settings/new-trusted-publisher.js
index 2231c9b0534..f236f6f0443 100644
--- a/app/controllers/crate/settings/new-trusted-publisher.js
+++ b/app/controllers/crate/settings/new-trusted-publisher.js
@@ -2,8 +2,9 @@ import Controller from '@ember/controller';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
+import Ember from 'ember';
-import { task } from 'ember-concurrency';
+import { rawTimeout, restartableTask, task } from 'ember-concurrency';
export default class NewTrustedPublisherController extends Controller {
@service notifications;
@@ -33,6 +34,34 @@ export default class NewTrustedPublisherController extends Controller {
}
}
+ get verificationUrl() {
+ if (this.repositoryOwner && this.repositoryName && this.workflowFilename) {
+ return `https://raw.githubusercontent.com/${this.repositoryOwner}/${this.repositoryName}/HEAD/.github/workflows/${this.workflowFilename}`;
+ }
+ }
+
+ verifyWorkflowTask = restartableTask(async () => {
+ let timeout = Ember.testing ? 0 : 500;
+ await rawTimeout(timeout);
+
+ let { verificationUrl } = this;
+ if (!verificationUrl) return null;
+
+ try {
+ let response = await fetch(verificationUrl, { method: 'HEAD' });
+
+ if (response.ok) {
+ return 'success';
+ } else if (response.status === 404) {
+ return 'not-found';
+ } else {
+ return 'error';
+ }
+ } catch {
+ return 'error';
+ }
+ });
+
saveConfigTask = task(async () => {
if (!this.validate()) return;
diff --git a/app/templates/crate/settings/new-trusted-publisher.css b/app/templates/crate/settings/new-trusted-publisher.css
index 020fddccf53..fb54ba322ce 100644
--- a/app/templates/crate/settings/new-trusted-publisher.css
+++ b/app/templates/crate/settings/new-trusted-publisher.css
@@ -45,3 +45,20 @@
.cancel-button {
border-radius: 4px;
}
+
+.workflow-verification {
+ margin-top: var(--space-2xs);
+ font-size: 0.85em;
+
+ :global(a), :global(a):hover {
+ color: inherit;
+ }
+}
+
+.workflow-verification--success {
+ color: var(--green800);
+}
+
+.workflow-verification--warning {
+ color: var(--yellow700);
+}
diff --git a/app/templates/crate/settings/new-trusted-publisher.gjs b/app/templates/crate/settings/new-trusted-publisher.gjs
index 18f92053cb7..b6e85c97f5b 100644
--- a/app/templates/crate/settings/new-trusted-publisher.gjs
+++ b/app/templates/crate/settings/new-trusted-publisher.gjs
@@ -7,6 +7,7 @@ import autoFocus from '@zestia/ember-auto-focus/modifiers/auto-focus';
import perform from 'ember-concurrency/helpers/perform';
import preventDefault from 'ember-event-helpers/helpers/prevent-default';
import eq from 'ember-truth-helpers/helpers/eq';
+import not from 'ember-truth-helpers/helpers/not';
import LoadingSpinner from 'crates-io/components/loading-spinner';
@@ -51,6 +52,7 @@ import LoadingSpinner from 'crates-io/components/loading-spinner';
data-test-repository-owner
{{autoFocus}}
{{on 'input' @controller.resetRepositoryOwnerValidation}}
+ {{on 'input' (perform @controller.verifyWorkflowTask)}}
/>
{{#if @controller.repositoryOwnerInvalid}}
@@ -79,6 +81,7 @@ import LoadingSpinner from 'crates-io/components/loading-spinner';
class='input base-input'
data-test-repository-name
{{on 'input' @controller.resetRepositoryNameValidation}}
+ {{on 'input' (perform @controller.verifyWorkflowTask)}}
/>
{{#if @controller.repositoryNameInvalid}}
@@ -107,6 +110,7 @@ import LoadingSpinner from 'crates-io/components/loading-spinner';
class='input base-input'
data-test-workflow-filename
{{on 'input' @controller.resetWorkflowFilenameValidation}}
+ {{on 'input' (perform @controller.verifyWorkflowTask)}}
/>
{{#if @controller.workflowFilenameInvalid}}
@@ -140,6 +144,41 @@ import LoadingSpinner from 'crates-io/components/loading-spinner';
publish.yml
.
{{/if}}
+
+ {{#if (not @controller.verificationUrl)}}
+
+ The workflow filename will be verified once all necessary fields are filled.
+
+ {{else if (eq @controller.verifyWorkflowTask.last.value 'success')}}
+
+ {{else if (eq @controller.verifyWorkflowTask.last.value 'not-found')}}
+
+ {{else if (eq @controller.verifyWorkflowTask.last.value 'error')}}
+
+ {{else}}
+
+ Verifying...
+
+ {{/if}}
{{/let}}
diff --git a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts
index d69fcf81b0d..78c90e30dc7 100644
--- a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts
+++ b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts
@@ -245,4 +245,77 @@ test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' }
});
}
});
+
+ test.describe('workflow verification', () => {
+ test('success case (200 OK)', async ({ msw, page }) => {
+ let { crate } = await prepare(msw);
+
+ await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`);
+ await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`);
+
+ await msw.worker.use(
+ http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml', () => {
+ return new HttpResponse(null, { status: 200 });
+ }),
+ );
+
+ await expect(page.locator('[data-test-workflow-verification="initial"]')).toHaveText(
+ 'The workflow filename will be verified once all necessary fields are filled.',
+ );
+
+ await page.fill('[data-test-repository-owner]', 'rust-lang');
+ await page.fill('[data-test-repository-name]', 'crates.io');
+ await page.fill('[data-test-workflow-filename]', 'ci.yml');
+
+ await expect(page.locator('[data-test-workflow-verification="success"]')).toHaveText(
+ '✓ Workflow file found at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml',
+ );
+ });
+
+ test('not found case (404)', async ({ msw, page }) => {
+ let { crate } = await prepare(msw);
+
+ await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`);
+ await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`);
+
+ await msw.worker.use(
+ http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/missing.yml', () => {
+ return new HttpResponse(null, { status: 404 });
+ }),
+ );
+
+ await page.fill('[data-test-repository-owner]', 'rust-lang');
+ await page.fill('[data-test-repository-name]', 'crates.io');
+ await page.fill('[data-test-workflow-filename]', 'missing.yml');
+
+ await expect(page.locator('[data-test-workflow-verification="not-found"]')).toHaveText(
+ '⚠ Workflow file not found at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/missing.yml',
+ );
+
+ // Verify form can still be submitted
+ await page.click('[data-test-add]');
+ await expect(page).toHaveURL(`/crates/${crate.name}/settings`);
+ });
+
+ test('server error (5xx)', async ({ msw, page }) => {
+ let { crate } = await prepare(msw);
+
+ await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`);
+ await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`);
+
+ await msw.worker.use(
+ http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml', () => {
+ return new HttpResponse(null, { status: 500 });
+ }),
+ );
+
+ await page.fill('[data-test-repository-owner]', 'rust-lang');
+ await page.fill('[data-test-repository-name]', 'crates.io');
+ await page.fill('[data-test-workflow-filename]', 'ci.yml');
+
+ await expect(page.locator('[data-test-workflow-verification="error"]')).toHaveText(
+ '⚠ Could not verify workflow file at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml (network error)',
+ );
+ });
+ });
});
diff --git a/packages/crates-io-msw/handlers/github.js b/packages/crates-io-msw/handlers/github.js
new file mode 100644
index 00000000000..0901e5a6a42
--- /dev/null
+++ b/packages/crates-io-msw/handlers/github.js
@@ -0,0 +1,7 @@
+import { http, HttpResponse } from 'msw';
+
+export default [
+ http.head('https://raw.githubusercontent.com/:owner/:projec/HEAD/.github/workflows/:workflow_filename', () => {
+ return new HttpResponse(null, { status: 404 });
+ }),
+];
diff --git a/packages/crates-io-msw/index.js b/packages/crates-io-msw/index.js
index 5973d4bb81a..4c4d65a6e6d 100644
--- a/packages/crates-io-msw/index.js
+++ b/packages/crates-io-msw/index.js
@@ -2,6 +2,7 @@ import apiTokenHandlers from './handlers/api-tokens.js';
import categoryHandlers from './handlers/categories.js';
import cratesHandlers from './handlers/crates.js';
import docsRsHandlers from './handlers/docs-rs.js';
+import githubHandlers from './handlers/github.js';
import inviteHandlers from './handlers/invites.js';
import keywordHandlers from './handlers/keywords.js';
import metadataHandlers from './handlers/metadata.js';
@@ -32,6 +33,7 @@ export const handlers = [
...categoryHandlers,
...cratesHandlers,
...docsRsHandlers,
+ ...githubHandlers,
...inviteHandlers,
...keywordHandlers,
...metadataHandlers,
diff --git a/tests/routes/crate/settings/new-trusted-publisher-test.js b/tests/routes/crate/settings/new-trusted-publisher-test.js
index 157231163ae..7c7adbfe8f2 100644
--- a/tests/routes/crate/settings/new-trusted-publisher-test.js
+++ b/tests/routes/crate/settings/new-trusted-publisher-test.js
@@ -252,4 +252,83 @@ module('Route | crate.settings.new-trusted-publisher', hooks => {
});
}
});
+
+ module('workflow verification', function () {
+ test('success case (200 OK)', async function (assert) {
+ let { crate } = prepare(this);
+
+ await visit(`/crates/${crate.name}/settings/new-trusted-publisher`);
+ assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`);
+
+ this.worker.use(
+ http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml', () => {
+ return new HttpResponse(null, { status: 200 });
+ }),
+ );
+
+ assert
+ .dom('[data-test-workflow-verification="initial"]')
+ .hasText('The workflow filename will be verified once all necessary fields are filled.');
+
+ await fillIn('[data-test-repository-owner]', 'rust-lang');
+ await fillIn('[data-test-repository-name]', 'crates.io');
+ await fillIn('[data-test-workflow-filename]', 'ci.yml');
+
+ await waitFor('[data-test-workflow-verification="success"]');
+
+ let expected =
+ '✓ Workflow file found at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml';
+ assert.dom('[data-test-workflow-verification="success"]').hasText(expected);
+ });
+
+ test('not found case (404)', async function (assert) {
+ let { crate } = prepare(this);
+
+ await visit(`/crates/${crate.name}/settings/new-trusted-publisher`);
+ assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`);
+
+ this.worker.use(
+ http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/missing.yml', () => {
+ return new HttpResponse(null, { status: 404 });
+ }),
+ );
+
+ await fillIn('[data-test-repository-owner]', 'rust-lang');
+ await fillIn('[data-test-repository-name]', 'crates.io');
+ await fillIn('[data-test-workflow-filename]', 'missing.yml');
+
+ await waitFor('[data-test-workflow-verification="not-found"]');
+
+ let expected =
+ '⚠ Workflow file not found at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/missing.yml';
+ assert.dom('[data-test-workflow-verification="not-found"]').hasText(expected);
+
+ // Verify form can still be submitted
+ await click('[data-test-add]');
+ assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`);
+ });
+
+ test('server error (5xx)', async function (assert) {
+ let { crate } = prepare(this);
+
+ await visit(`/crates/${crate.name}/settings/new-trusted-publisher`);
+ assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`);
+
+ this.worker.use(
+ http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml', () => {
+ return new HttpResponse(null, { status: 500 });
+ }),
+ );
+
+ await fillIn('[data-test-repository-owner]', 'rust-lang');
+ await fillIn('[data-test-repository-name]', 'crates.io');
+ await fillIn('[data-test-workflow-filename]', 'ci.yml');
+
+ await waitFor('[data-test-workflow-verification="error"]');
+
+ let expected =
+ '⚠ Could not verify workflow file at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml (network error)';
+ assert.dom('[data-test-workflow-verification="error"]').hasText(expected);
+ });
+ });
});