feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
@@ -1,20 +1,16 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {login, apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts';
import {login, apiCreateRepo, randomString} from './utils.ts';
test('codeeditor textarea updates correctly', async ({page, request}) => {
const repoName = `e2e-codeeditor-${randomString(8)}`;
await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]);
try {
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`);
await page.getByPlaceholder('Name your file…').fill('test.js');
await expect(page.locator('.editor-loading')).toBeHidden();
const editor = page.locator('.cm-content[role="textbox"]');
await expect(editor).toBeVisible();
await editor.click();
await page.keyboard.type('const hello = "world";');
await expect(page.locator('textarea[name="content"]')).toHaveValue('const hello = "world";');
} finally {
await apiDeleteRepo(request, env.GITEA_TEST_E2E_USER, repoName);
}
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`);
await page.getByPlaceholder('Name your file…').fill('test.js');
await expect(page.locator('[data-tab="write"] .editor-loading')).toBeHidden();
const editor = page.locator('.cm-content[role="textbox"]');
await expect(editor).toBeVisible();
await editor.click();
await page.keyboard.type('const hello = "world";');
await expect(page.locator('textarea[name="content"]')).toHaveValue('const hello = "world";');
});
+14 -19
View File
@@ -1,5 +1,5 @@
import {test, expect} from '@playwright/test';
import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiDeleteUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch, timeoutFactor, randomString} from './utils.ts';
import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch, timeoutFactor, randomString} from './utils.ts';
// These tests rely on a short EVENT_SOURCE_UPDATE_TIME in the e2e server config.
test.describe('events', () => {
@@ -12,20 +12,18 @@ test.describe('events', () => {
// Create repo and login in parallel — repo is needed for the issue, login for the event stream
await Promise.all([
apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(owner)}),
apiCreateRepo(request, {name: repoName, autoInit: false, headers: apiUserHeaders(owner)}),
loginUser(page, owner),
]);
await page.goto('/');
const badge = page.locator('a.not-mobile .notification_count');
await expect(badge).toBeHidden();
// Create issue as another user — this generates a notification delivered via server push
await apiCreateIssue(request, owner, repoName, {title: 'events notification test', headers: apiUserHeaders(commenter)});
await apiCreateIssue(request, {owner, repo: repoName, title: 'events notification test', headers: apiUserHeaders(commenter)});
// Wait for the notification badge to appear via server event
await expect(badge).toBeVisible({timeout: 15000 * timeoutFactor});
// Cleanup
await Promise.all([apiDeleteUser(request, commenter), apiDeleteUser(request, owner)]);
});
test('stopwatch', async ({page, request}) => {
@@ -34,20 +32,20 @@ test.describe('events', () => {
await apiCreateUser(request, name);
// Create repo, issue, and start stopwatch before login
await apiCreateRepo(request, {name, headers});
await apiCreateIssue(request, name, name, {title: 'events stopwatch test', headers});
await apiStartStopwatch(request, name, name, 1, {headers});
// Login — page renders with the active stopwatch element
await loginUser(page, name);
// Login in parallel with repo+issue+stopwatch setup (all independent after user exists)
await Promise.all([
loginUser(page, name),
(async () => {
await apiCreateRepo(request, {name, autoInit: false, headers});
await apiCreateIssue(request, {owner: name, repo: name, title: 'events stopwatch test', headers});
await apiStartStopwatch(request, name, name, 1, {headers});
})(),
]);
await page.goto('/');
// Verify stopwatch is visible and links to the correct issue
const stopwatch = page.locator('.active-stopwatch.not-mobile');
await expect(stopwatch).toBeVisible();
// Cleanup
await apiDeleteUser(request, name);
});
test('logout propagation', async ({browser, request}) => {
@@ -82,8 +80,5 @@ test.describe('events', () => {
await expect(page2.getByRole('link', {name: 'Sign In'})).toBeVisible();
await context.close();
// Cleanup
await apiDeleteUser(request, name);
});
});
@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {login, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
import {login, apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, randomString} from './utils.ts';
test('external file', async ({page, request}) => {
const repoName = `e2e-external-render-${randomString(8)}`;
@@ -9,19 +9,15 @@ test('external file', async ({page, request}) => {
apiCreateRepo(request, {name: repoName}),
login(page),
]);
try {
await apiCreateFile(request, owner, repoName, 'test.external', '<p>rendered content</p>');
await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`));
const frame = page.frameLocator('iframe.external-render-iframe');
await expect(frame.locator('p')).toContainText('rendered content');
await assertFlushWithParent(iframe, page.locator('.file-view'));
await assertNoJsError(page);
} finally {
await apiDeleteRepo(request, owner, repoName);
}
await apiCreateFile(request, owner, repoName, 'test.external', '<p>rendered content</p>');
await page.goto(`/${owner}/${repoName}/src/branch/main/test.external`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
await expect(iframe).toHaveAttribute('data-src', new RegExp(`/${owner}/${repoName}/render/branch/main/test\\.external`));
const frame = page.frameLocator('iframe.external-render-iframe');
await expect(frame.locator('p')).toContainText('rendered content');
await assertFlushWithParent(iframe, page.locator('.file-view'));
await assertNoJsError(page);
});
test('openapi file', async ({page, request}) => {
@@ -31,31 +27,27 @@ test('openapi file', async ({page, request}) => {
apiCreateRepo(request, {name: repoName}),
login(page),
]);
try {
const title = 'Test <API> & "quoted"';
const spec = JSON.stringify({
openapi: '3.0.0',
info: {title, version: '1.0'},
paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}},
components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}},
});
await apiCreateFile(request, owner, repoName, 'openapi.json', spec);
await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
const viewer = page.frameLocator('iframe.external-render-iframe').locator('#frontend-render-viewer');
await expect(viewer.locator('.swagger-ui')).toBeVisible();
await expect(viewer.locator('.info .title')).toContainText(title);
// expanding the operation triggers swagger-ui's $ref resolver, which fetches window.location
// (about:srcdoc since the iframe is loaded via srcdoc); failure surfaces as "Could not resolve reference"
await viewer.locator('.opblock-tag').first().click();
await viewer.locator('.opblock').first().click();
await expect(viewer.getByText('Could not resolve reference')).toHaveCount(0);
// poll: postMessage resize may not have settled yet when the visibility checks pass
await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(iframe, page.locator('.file-view'));
await assertNoJsError(page);
} finally {
await apiDeleteRepo(request, owner, repoName);
}
const title = 'Test <API> & "quoted"';
const spec = JSON.stringify({
openapi: '3.0.0',
info: {title, version: '1.0'},
paths: {'/pets': {get: {responses: {'200': {description: 'OK', content: {'application/json': {schema: {$ref: '#/components/schemas/Pet'}}}}}}}},
components: {schemas: {Pet: {type: 'object', properties: {children: {type: 'array', items: {$ref: '#/components/schemas/Pet'}}}}}},
});
await apiCreateFile(request, owner, repoName, 'openapi.json', spec);
await page.goto(`/${owner}/${repoName}/src/branch/main/openapi.json`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
const viewer = page.frameLocator('iframe.external-render-iframe').locator('#frontend-render-viewer');
await expect(viewer.locator('.swagger-ui')).toBeVisible();
await expect(viewer.locator('.info .title')).toContainText(title);
// expanding the operation triggers swagger-ui's $ref resolver, which fetches window.location
// (about:srcdoc since the iframe is loaded via srcdoc); failure surfaces as "Could not resolve reference"
await viewer.locator('.opblock-tag').first().click();
await viewer.locator('.opblock').first().click();
await expect(viewer.getByText('Could not resolve reference')).toHaveCount(0);
// poll: postMessage resize may not have settled yet when the visibility checks pass
await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(iframe, page.locator('.file-view'));
await assertNoJsError(page);
});
@@ -1,32 +1,29 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {apiCreateBranch, apiCreateRepo, apiCreateFile, apiDeleteRepo, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
import {apiCreateRepo, apiCreateFile, assertFlushWithParent, assertNoJsError, login, randomString} from './utils.ts';
test('3d model file', async ({page, request}) => {
test('3d model file', async ({page, request, browserName}) => {
test.skip(browserName === 'firefox', 'unclear firefox-only CI-only failure'); // eslint-disable-line playwright/no-skipped-test
const repoName = `e2e-3d-render-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
try {
const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n';
await apiCreateFile(request, owner, repoName, 'test.stl', stl);
await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
const frame = page.frameLocator('iframe.external-render-iframe');
const viewer = frame.locator('#frontend-render-viewer');
await expect(viewer.locator('canvas')).toBeVisible();
expect((await viewer.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(iframe, page.locator('.file-view'));
// bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent
const [parentBg, iframeBg] = await Promise.all([
page.evaluate(() => getComputedStyle(document.body).backgroundColor),
frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor),
]);
expect(iframeBg).toBe(parentBg);
await assertNoJsError(page);
} finally {
await apiDeleteRepo(request, owner, repoName);
}
const stl = 'solid test\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid test\n';
await apiCreateFile(request, owner, repoName, 'test.stl', stl);
await page.goto(`/${owner}/${repoName}/src/branch/main/test.stl?display=rendered`);
const iframe = page.locator('iframe.external-render-iframe');
await expect(iframe).toBeVisible();
const frame = page.frameLocator('iframe.external-render-iframe');
const viewer = frame.locator('#frontend-render-viewer');
await expect(viewer.locator('canvas')).toBeVisible(); // unclear firefox-only CI-only failure
expect((await viewer.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(iframe, page.locator('.file-view'));
// bgcolor passed via gitea-iframe-bgcolor; 3D viewer reads it from body bgcolor — must match parent
const [parentBg, iframeBg] = await Promise.all([
page.evaluate(() => getComputedStyle(document.body).backgroundColor),
frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor),
]);
expect(iframeBg).toBe(parentBg);
await assertNoJsError(page);
});
test('pdf file', async ({page, request}) => {
@@ -34,36 +31,33 @@ test('pdf file', async ({page, request}) => {
const repoName = `e2e-pdf-render-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
try {
await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n');
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
const container = page.locator('.file-view-render-container');
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
expect((await container.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(container, page.locator('.file-view'));
} finally {
await apiDeleteRepo(request, owner, repoName);
}
await apiCreateFile(request, owner, repoName, 'test.pdf', '%PDF-1.0\n%%EOF\n');
await page.goto(`/${owner}/${repoName}/src/branch/main/test.pdf`);
const container = page.locator('.file-view-render-container');
await expect(container).toHaveAttribute('data-render-name', 'pdf-viewer');
await expect.poll(async () => (await container.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(container, page.locator('.file-view'));
});
test('asciicast file', async ({page, request}) => {
// regression for repo_file.go's RefTypeNameSubURL double-escape: readme.cast on a non-ASCII branch
// is rendered via view_readme.go (no metas override), exposing the bug as a broken player URL
const repoName = `e2e-asciicast-render-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
const branch = '日本語-branch';
const branchEnc = encodeURIComponent(branch);
await Promise.all([apiCreateRepo(request, {name: repoName, autoInit: false}), login(page)]);
try {
const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "hi"]\n';
await apiCreateFile(request, owner, repoName, 'readme.cast', cast);
await apiCreateBranch(request, owner, repoName, branch);
await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}`);
const container = page.locator('.asciinema-player-container');
await expect(container).toHaveAttribute('data-asciinema-player-src', `/${owner}/${repoName}/raw/branch/${branchEnc}/readme.cast`);
await expect(container.locator('.ap-wrapper')).toBeVisible();
expect((await container.boundingBox())!.height).toBeGreaterThan(300);
} finally {
await apiDeleteRepo(request, owner, repoName);
}
const cast = '{"version": 2, "width": 80, "height": 24}\n[0.0, "o", "test-content"]\n';
// on an empty repo, apiCreateFile with newBranch creates that branch as the initial commit
await apiCreateFile(request, owner, repoName, 'test.cast', cast, {newBranch: branch});
await page.goto(`/${owner}/${repoName}/src/branch/${branchEnc}/test.cast`);
const iframe = page.locator('iframe.external-render-iframe');
const frame = iframe.contentFrame();
const viewer = frame.locator('#frontend-render-viewer[data-frontend-render-name]');
await expect(viewer).toHaveAttribute('data-frontend-render-name', 'asciicast'); // render succeeded
await expect(viewer).toHaveAttribute('data-window-origin', 'null'); // no same-origin, avoid XSS
const wrapper = frame.locator('.ap-wrapper');
await expect(wrapper).toBeVisible();
await expect(wrapper).toContainText('test-content');
await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(300);
await assertFlushWithParent(iframe, page.locator('.file-view'));
await assertNoJsError(page);
});
@@ -0,0 +1,18 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateUser, apiUserHeaders, randomString} from './utils.ts';
test('fork a repository', async ({page, request}) => {
const upstream = `fork-owner-${randomString(8)}`;
const repoName = `e2e-fork-${randomString(8)}`;
await apiCreateUser(request, upstream);
await Promise.all([
apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(upstream)}),
login(page),
]);
await page.goto(`/${upstream}/${repoName}/fork`);
await page.getByRole('button', {name: 'Fork Repository'}).click();
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}$`));
await expect(page.getByRole('link', {name: `${upstream}/${repoName}`})).toBeVisible();
});
@@ -0,0 +1,9 @@
import {test, expect} from '@playwright/test';
import {login} from './utils.ts';
test('heatmap tooltip shows on hover', async ({page}) => {
await login(page);
await page.goto('/');
await page.locator('.heatmap-day').first().hover();
await expect(page.locator('.tippy-box[data-state="visible"]')).toBeVisible();
});
@@ -0,0 +1,26 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts';
test('comment on and close an issue', async ({page, request}) => {
const repoName = `e2e-issue-comment-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName, autoInit: false});
await Promise.all([
apiCreateIssue(request, {owner, repo: repoName, title: 'Comment test'}),
login(page),
]);
await page.goto(`/${owner}/${repoName}/issues/1`);
const body = `e2e-comment-${randomString(8)}`;
await page.getByPlaceholder('Leave a comment').fill(body);
// exact match: the status button reads "Close with Comment" while the box has content, which substring-matches "Comment"
await page.getByRole('button', {name: 'Comment', exact: true}).click();
await expect(page.locator('.comment-body').filter({hasText: body})).toBeVisible();
// wait for the form to re-initialize (the empty box disables the comment button); a close click
// before that does a native submit which lands on a raw JSON page instead of reloading the issue
await expect(page.getByRole('button', {name: 'Comment', exact: true})).toBeDisabled();
await page.getByRole('button', {name: 'Close Issue'}).click();
await expect(page.getByRole('button', {name: 'Reopen Issue'})).toBeVisible();
});
@@ -0,0 +1,443 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProject, createProjectColumn, randomString} from './utils.ts';
test('assign issue to project and change column', async ({page}) => {
const repoName = `e2e-issue-project-${randomString(8)}`;
const user = env.GITEA_TEST_E2E_USER;
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName, autoInit: false})]);
await page.goto(`/${user}/${repoName}/projects/new`);
await page.locator('input[name="title"]').fill('Kanban Board');
await page.getByRole('button', {name: 'Create Project'}).click();
const projectLink = page.locator('.milestone-list a', {hasText: 'Kanban Board'}).first();
await expect(projectLink).toBeVisible();
const href = await projectLink.getAttribute('href');
const projectID = href!.split('/').pop()!;
// columns created via POST because the web UI uses modals that are hard to drive
await Promise.all([
...['Backlog', 'In Progress', 'Done'].map((title) => createProjectColumn(page.request, user, repoName, projectID, title)),
apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Column picker test'}),
]);
await page.goto(`/${user}/${repoName}/issues/1`);
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
await page.locator('.sidebar-project-combo > .ui.dropdown .item:has-text("Kanban Board")').click();
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
await page.locator('.sidebar-project-column-combo .ui.dropdown').click();
await page.locator('.sidebar-project-column-combo .ui.dropdown .item:has-text("In Progress")').click();
await expect(page.locator('.sidebar-project-column-combo .ui.dropdown .fixed-text')).toHaveText('In Progress');
await apiDeleteRepo(page.request, user, repoName);
});
test('create a project', async ({page}) => {
const repoName = `e2e-project-repo-${Date.now()}`;
const projectTitle = 'Test Project';
await login(page);
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
try {
// Navigate to new project page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/new`);
// Fill in project details
await page.getByLabel('Title').fill(projectTitle);
// Submit the form
await page.getByRole('button', {name: 'Create Project'}).click();
// Verify project was created by checking we're redirected to the projects list
await expect(page).toHaveURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects$`));
// Verify the project appears in the list
await expect(page.locator('.milestone-list')).toContainText(projectTitle);
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('assign issue to multiple projects via sidebar', async ({page}) => {
const repoName = `e2e-multi-project-${Date.now()}`;
const project1Title = 'Project Alpha';
const project2Title = 'Project Beta';
const issueTitle = 'Test issue for multiple projects';
await login(page);
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
try {
// Create two projects via UI
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Create an issue without any project
const issue = await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: issueTitle,
});
// Navigate to the issue page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
// Open the projects dropdown in the sidebar
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
// Select both projects
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
// Click outside to close the dropdown and trigger the update
await page.locator('.issue-content-left').click();
// Verify both projects are shown in the sidebar
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('create issue with multiple projects pre-selected', async ({page}) => {
const repoName = `e2e-issue-multi-proj-${Date.now()}`;
const project1Title = 'Project One';
const project2Title = 'Project Two';
const issueTitle = 'Issue with multiple projects';
await login(page);
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
try {
// Create two projects via UI
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Navigate to new issue page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
// Fill in the issue title
await page.locator('input[name="title"]').fill(issueTitle);
// Open the projects dropdown
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
// Select both projects
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
// Click outside to close the dropdown
await page.locator('.issue-content-left').click();
// Submit the form
await page.getByRole('button', {name: 'Create Issue'}).click();
// Wait for issue to be created and page to redirect
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/\\d+`));
// Verify both projects are shown in the sidebar
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('filter issues by multiple projects in issue list', async ({page}) => {
const repoName = `e2e-filter-projects-${Date.now()}`;
const project1Title = 'Filter Project A';
const project2Title = 'Filter Project B';
await login(page);
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
try {
// Create two projects via UI
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Create issues: one in project1, one in project2, one in both
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue in Project A only',
projects: [project1.id],
});
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue in Project B only',
projects: [project2.id],
});
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue in both projects',
projects: [project1.id, project2.id],
});
// Create an issue with no project
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue with no project',
});
// Verify only project1 issues are visible
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project1.id}`);
await expect(page.locator('#issue-list')).toContainText('Issue in Project A only');
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project B only');
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
// Verify only project2 issues are visible
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project2.id}`);
await expect(page.locator('#issue-list')).toContainText('Issue in Project B only');
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project A only');
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('remove issue from one project keeping others', async ({page}) => {
const repoName = `e2e-remove-project-${Date.now()}`;
const project1Title = 'Keep This Project';
const project2Title = 'Remove This Project';
const issueTitle = 'Issue to modify projects';
await login(page);
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
try {
// Create two projects via UI
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Create an issue in both projects
const issue = await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: issueTitle,
projects: [project1.id, project2.id],
});
// Navigate to the issue page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
// Verify both projects are initially shown
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeVisible();
// Open the projects dropdown
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
// Deselect project2 (click on the already selected item to deselect)
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
// Click outside to close the dropdown and trigger the update
await page.locator('.issue-content-left').click();
// Verify project1 is still shown but project2 is removed
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project1Title}")`)).toBeVisible();
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeHidden();
// Reload the page to see the timeline comment
await page.reload();
// Verify the timeline shows "removed this from the project" comment
const timelineComments = page.locator('.timeline-item.event');
await expect(timelineComments.filter({hasText: 'removed this from the'})).toBeVisible();
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('filter issues with no project using project=-1', async ({page}) => {
const repoName = `e2e-no-project-filter-${Date.now()}`;
const projectTitle = 'Some Project';
await login(page);
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
try {
// Create a project via UI
const project = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: projectTitle,
});
// Create an issue with a project
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue with project assigned',
projects: [project.id],
});
// Create issues with no project
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Issue without any project',
});
await apiCreateIssue(page.request, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: 'Another unassigned issue',
});
// First verify we can see all issues without the filter
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open`);
await expect(page.locator('#issue-list')).toContainText('Issue with project assigned');
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
// Navigate to issue list filtering for issues with no project (project=-1)
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open&project=-1`);
// Verify only issues with no project are visible
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
// Verify the issue with a project is NOT visible
await expect(page.locator('#issue-list')).not.toContainText('Issue with project assigned');
// Verify the last item in the list is NOT the issue with a project
const issueItems = page.locator('#issue-list .item');
const lastIssueItem = issueItems.last();
await expect(lastIssueItem).not.toContainText('Issue with project assigned');
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('close project and view in closed projects list', async ({page}) => {
const repoName = `e2e-close-project-${Date.now()}`;
const openProjectTitle = 'Open Project';
const closedProjectTitle = 'Project To Close';
await login(page);
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
try {
// Create two projects via UI
await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: openProjectTitle,
});
const projectToClose = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: closedProjectTitle,
});
// Navigate to projects list
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
// Verify both projects are visible in open state
await expect(page.locator('.milestone-list')).toContainText(openProjectTitle);
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
// Close the second project by clicking the close link
const projectCard = page.locator('.milestone-list > .item').filter({hasText: closedProjectTitle});
await projectCard.locator('a.link-action[data-url$="/close"]').click();
// Wait for redirect back to project view page
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/${projectToClose.id}`));
// Navigate to projects list
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
// Click on "Closed" tab to view closed projects
await page.locator('.list-header-toggle a.item').filter({hasText: 'Closed'}).click();
// Wait for the page to load with closed projects
await page.waitForURL(/state=closed/);
// Verify only the closed project is visible
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
await expect(page.locator('.milestone-list')).not.toContainText(openProjectTitle);
// Verify the "Closed" tab is active
await expect(page.locator('.list-header-toggle a.item.active')).toContainText('Closed');
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
test('select projects on new issue page shows in sidebar', async ({page}) => {
const repoName = `e2e-new-issue-project-${Date.now()}`;
const project1Title = 'Project One';
const project2Title = 'Project Two';
await login(page);
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
try {
// Create two projects
const project1 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project1Title,
});
const project2 = await createProject(page, {
owner: env.GITEA_TEST_E2E_USER,
repo: repoName,
title: project2Title,
});
// Navigate to new issue page
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
// Open the projects dropdown in the sidebar
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
// Select both projects
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
// Click outside to close dropdown
await page.locator('.issue-content-left').click();
// Verify both projects appear in the sidebar list below the dropdown
// On new issue page, these are simple cloned items rendered in the list container
const projectList = page.locator('.sidebar-project-combo > .ui.list');
await expect(projectList.locator(`.item:has-text("${project1Title}")`).first()).toBeVisible();
await expect(projectList.locator(`.item:has-text("${project2Title}")`).first()).toBeVisible();
} finally {
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
}
});
@@ -5,5 +5,5 @@ test('licenses.txt', async ({page}) => {
expect(resp?.status()).toBe(200);
const content = await resp!.text();
expect(content).toContain('@vue/');
expect(content).toContain('code.gitea.io/');
expect(content).toContain('gitea.dev/');
});
+8 -3
View File
@@ -1,12 +1,17 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, logout} from './utils.ts';
import {logout} from './utils.ts';
test('homepage', async ({page}) => {
await page.goto('/');
await expect(page.getByRole('img', {name: 'Logo'})).toHaveAttribute('src', '/assets/img/logo.svg');
});
test('login and logout', async ({page}) => {
await login(page);
test('login form and logout', async ({page}) => {
await page.goto('/user/login');
await page.getByLabel('Username or Email Address').fill(env.GITEA_TEST_E2E_USER);
await page.getByLabel('Password').fill(env.GITEA_TEST_E2E_PASSWORD);
await page.getByRole('button', {name: 'Sign In'}).click();
await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden();
await logout(page);
});
@@ -1,19 +1,14 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {apiCreateRepo, apiHeaders, assertNoJsError, baseUrl, randomString} from './utils.ts';
import {apiCreateRepo, apiCreateIssue, assertNoJsError, randomString} from './utils.ts';
test('mermaid diagram in issue', async ({page, request}) => {
const repoName = `e2e-mermaid-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await apiCreateRepo(request, {name: repoName, autoInit: false});
const body = '```mermaid\nflowchart LR\n Alpha --> Beta\n Beta --> Gamma\n```\n';
const response = await request.post(`${baseUrl()}/api/v1/repos/${owner}/${repoName}/issues`, {
headers: apiHeaders(),
data: {title: 'mermaid test', body},
});
expect(response.ok(), `create issue failed: ${response.status()}`).toBe(true);
const {number} = await response.json();
await page.goto(`/${owner}/${repoName}/issues/${number}`);
const {index} = await apiCreateIssue(request, {owner, repo: repoName, title: 'mermaid test', body});
await page.goto(`/${owner}/${repoName}/issues/${index}`);
const svg = page.frameLocator('iframe.markup-content-iframe').locator('svg');
await expect(svg).toContainText(/Alpha[\s\S]*Beta[\s\S]*Gamma/);
@@ -1,13 +1,12 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts';
import {login, apiCreateRepo, randomString} from './utils.ts';
test('create a milestone', async ({page}) => {
const repoName = `e2e-milestone-${randomString(8)}`;
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName})]);
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName, autoInit: false})]);
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/milestones/new`);
await page.getByPlaceholder('Title').fill('Test Milestone');
await page.getByRole('button', {name: 'Create Milestone'}).click();
await expect(page.locator('.milestone-list')).toContainText('Test Milestone');
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
});
+40 -1
View File
@@ -1,5 +1,5 @@
import {test, expect} from '@playwright/test';
import {login, apiDeleteOrg, randomString} from './utils.ts';
import {login, apiCreateOrg, apiCreateTeam, apiCreateUser, apiDeleteOrg, randomString} from './utils.ts';
test('create an organization', async ({page}) => {
const orgName = `e2e-org-${randomString(8)}`;
@@ -11,3 +11,42 @@ test('create an organization', async ({page}) => {
// delete via API because of issues related to form-fetch-action
await apiDeleteOrg(page.request, orgName);
});
test('add team member search', async ({page, request}) => {
const orgName = `team-add-${randomString(8)}`;
const teamName = `team-add-${randomString(8)}`;
const userName = `team-add-${randomString(8)}`;
await Promise.all([
(async () => {
await apiCreateOrg(request, orgName);
await apiCreateTeam(request, orgName, teamName);
})(),
apiCreateUser(request, userName),
login(page),
]);
await page.goto(`/org/${orgName}/teams/${teamName}`);
const input = page.locator('#search-user-box input.prompt');
await input.fill(userName.slice(-6));
const result = page.locator('#search-user-box .results .result').first();
await expect(result).toContainText(userName);
});
test('delete team via confirm modal', async ({page, request}) => {
const orgName = `e2e-del-team-${randomString(8)}`;
const teamName = `team-${randomString(8)}`;
await Promise.all([
(async () => {
await apiCreateOrg(request, orgName);
await apiCreateTeam(request, orgName, teamName);
})(),
login(page),
]);
await page.goto(`/org/${orgName}/teams/${teamName}/edit`);
await page.getByRole('button', {name: 'Delete Team'}).click();
await page.getByRole('button', {name: 'Yes'}).click();
await expect(page).toHaveURL(new RegExp(`/org/${orgName}/teams$`));
await expect(page.getByText('The team has been deleted.')).toBeVisible();
});
@@ -0,0 +1,23 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateFile, randomString} from './utils.ts';
test('create a pull request from the compare page', async ({page, request}) => {
const repoName = `e2e-pr-create-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await Promise.all([
apiCreateFile(request, owner, repoName, 'feat.txt', 'feature content\n', {branch: 'main', newBranch: 'feat'}),
login(page),
]);
// expand=1 renders the PR form directly, skipping the "New Pull Request" toggle click
await page.goto(`/${owner}/${repoName}/compare/main...feat?expand=1`);
const title = `e2e-pr-${randomString(8)}`;
await page.getByPlaceholder('Title').fill(title);
await page.getByRole('button', {name: 'Create Pull Request'}).click();
// commit, not full load: the PR title heading is server-rendered, so the assertion can resolve before the heavy diff/timeline finishes
await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+$`), {waitUntil: 'commit'});
await expect(page.getByRole('heading', {name: title})).toBeVisible();
});
@@ -0,0 +1,53 @@
import {test, expect} from '@playwright/test';
import {apiCreateFile, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
test('pr review flow', async ({page, request}) => {
const poster = `rv-poster-${randomString(8)}`;
const reviewer = `rv-reviewer-${randomString(8)}`;
await Promise.all([apiCreateUser(request, poster), apiCreateUser(request, reviewer)]);
const posterHeaders = apiUserHeaders(poster);
const repoName = `e2e-prreview-${randomString(8)}`;
await apiCreateRepo(request, {name: repoName, headers: posterHeaders});
await apiCreateFile(request, poster, repoName, 'added.txt', 'new content\n', {branch: 'main', newBranch: 'feat'});
const prIndex = await apiCreatePR(request, poster, repoName, 'feat', 'main', 'review test', {headers: posterHeaders});
// reviewer seeds an inline comment via API so the poster's UI reply exercises the reply-to-review path (#35994)
await Promise.all([
apiCreateReview(request, poster, repoName, prIndex, {
comments: [{path: 'added.txt', body: 'inline to reply to', new_position: 1}],
headers: apiUserHeaders(reviewer),
}),
loginUser(page, poster),
]);
await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`);
// diff viewer renders the added file with its header and one added-line row
const fileBox = page.locator('.diff-file-box[data-new-filename="added.txt"]');
await expect(fileBox.locator('.diff-file-header .file-link')).toHaveText('added.txt');
await expect(fileBox.locator('tr.add-code')).toHaveCount(1);
// commits tab badge reflects the single PR commit, and the diff stats header counts one changed file
const commitsTab = page.locator('.ui.pull.tabular.menu a.item', {has: page.locator('.octicon-git-commit')});
await expect(commitsTab.locator('.label')).toHaveText('1');
await expect(page.locator('.diff-detail-stats')).toContainText(/1 changed file/);
// poster replies to the reviewer's inline comment
const conversation = fileBox.locator('.conversation-holder');
await conversation.locator('.comment-form-reply').click();
const replyForm = conversation.locator('form');
await replyForm.locator('textarea[name="content"]').fill('my reply body');
await replyForm.getByRole('button', {name: 'Reply', exact: true}).click();
await expect(conversation.locator('.comment-body')).toContainText(['inline to reply to', 'my reply body']);
// switch to reviewer and submit an approve review
await page.context().clearCookies();
await loginUser(page, reviewer);
await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`);
await page.locator('#review-box .js-btn-review').click();
const panel = page.locator('.review-box-panel');
await panel.locator('textarea[name="content"]').fill('LGTM');
await panel.getByRole('button', {name: 'Approve', exact: true}).click();
await expect(page.locator('.timeline-item .octicon-check').first()).toBeVisible();
await expect(page.locator('.timeline-item').filter({hasText: 'LGTM'})).toBeVisible();
});
@@ -1,30 +1,26 @@
import {env} from 'node:process';
import {expect, test} from '@playwright/test';
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, randomString} from './utils.ts';
import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts';
test('toggle issue reactions', async ({page, request}) => {
const repoName = `e2e-reactions-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await apiCreateRepo(request, {name: repoName});
await apiCreateRepo(request, {name: repoName, autoInit: false});
await Promise.all([
apiCreateIssue(request, owner, repoName, {title: 'Reaction test'}),
apiCreateIssue(request, {owner, repo: repoName, title: 'Reaction test'}),
login(page),
]);
try {
await page.goto(`/${owner}/${repoName}/issues/1`);
await page.goto(`/${owner}/${repoName}/issues/1`);
const issueComment = page.locator('.timeline-item.comment.first');
const issueComment = page.locator('.timeline-item.comment.issue-content-comment');
const reactionPicker = issueComment.locator('.select-reaction');
await reactionPicker.click();
await reactionPicker.getByLabel('+1').click();
const reactionPicker = issueComment.locator('.select-reaction');
await reactionPicker.click();
await reactionPicker.getByLabel('+1').click();
const reactions = issueComment.getByRole('group', {name: 'Reactions'});
await expect(reactions.getByRole('button', {name: /^\+1:/})).toContainText('1');
const reactions = issueComment.getByRole('group', {name: 'Reactions'});
await expect(reactions.getByRole('button', {name: /^\+1:/})).toContainText('1');
await reactions.getByRole('button', {name: /^\+1:/}).click();
await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0);
} finally {
await apiDeleteRepo(request, owner, repoName);
}
await reactions.getByRole('button', {name: /^\+1:/}).click();
await expect(reactions.getByRole('button', {name: /^\+1:/})).toHaveCount(0);
});
@@ -1,11 +1,10 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {apiCreateRepo, apiDeleteRepo, randomString} from './utils.ts';
import {apiCreateRepo, randomString} from './utils.ts';
test('repo readme', async ({page}) => {
const repoName = `e2e-readme-${randomString(8)}`;
await apiCreateRepo(page.request, {name: repoName});
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}`);
await expect(page.locator('#readme')).toContainText(repoName);
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
});
@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, logout, apiDeleteUser, randomString} from './utils.ts';
import {login, logout, randomString} from './utils.ts';
test.beforeEach(async ({page}) => {
await page.goto('/user/sign_up');
@@ -48,9 +48,6 @@ test('register then login', async ({page}) => {
// Logout then login with the newly created account
await logout(page);
await login(page, username, password);
// delete via API because of issues related to form-fetch-action
await apiDeleteUser(page.request, username);
});
test('register with existing username shows error', async ({page}) => {
@@ -0,0 +1,19 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, randomString} from './utils.ts';
test('create a release', async ({page, request}) => {
const repoName = `e2e-release-${randomString(8)}`;
const owner = env.GITEA_TEST_E2E_USER;
await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]);
await page.goto(`/${owner}/${repoName}/releases/new`);
const tag = `v1.0.0-${randomString(8)}`;
const title = `e2e-release-${randomString(8)}`;
await page.getByLabel('Tag name').fill(tag);
await page.getByLabel('Release title').fill(title);
await page.getByRole('button', {name: 'Publish Release'}).click();
await page.waitForURL(new RegExp(`/${owner}/${repoName}/releases$`));
await expect(page.locator('.release-list-title')).toContainText(title);
});
@@ -0,0 +1,24 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {apiCreateRepo, apiCreateUser, login, randomString} from './utils.ts';
test('add collaborator search', async ({page, request}) => {
const userName = `repo-collab-${randomString(8)}`;
const repoName = `repo-collab-${randomString(8)}`;
await Promise.all([
apiCreateUser(request, userName),
apiCreateRepo(request, {name: repoName, autoInit: false}),
login(page),
]);
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/settings/collaboration`);
const input = page.locator('#search-user-box input.prompt');
await input.fill(userName.slice(-6));
const result = page.locator('#search-user-box .results .result').first();
await expect(result).toContainText(userName);
await result.click();
await expect(input).toHaveValue(userName);
await page.getByRole('button', {name: 'Add Collaborator'}).click();
await expect(page.locator('body')).toContainText(userName);
});
@@ -0,0 +1,20 @@
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateUser, apiUserHeaders, randomString} from './utils.ts';
test('star and watch a repository', async ({page, request}) => {
const owner = `sw-owner-${randomString(8)}`;
const repoName = `e2e-star-watch-${randomString(8)}`;
await apiCreateUser(request, owner);
await Promise.all([
apiCreateRepo(request, {name: repoName, autoInit: false, headers: apiUserHeaders(owner)}),
login(page),
]);
await page.goto(`/${owner}/${repoName}`);
// exact match so "Star"/"Watch" don't also match "Unstar"/"Unwatch"
await page.getByRole('button', {name: 'Star', exact: true}).click();
await expect(page.getByRole('button', {name: 'Unstar'})).toBeVisible();
await page.getByRole('button', {name: 'Watch', exact: true}).click();
await expect(page.getByRole('button', {name: 'Unwatch'})).toBeVisible();
});
+1 -2
View File
@@ -1,6 +1,6 @@
import {env} from 'node:process';
import {test} from '@playwright/test';
import {login, apiDeleteRepo, randomString} from './utils.ts';
import {login, randomString} from './utils.ts';
test('create a repository', async ({page}) => {
const repoName = `e2e-repo-${randomString(8)}`;
@@ -9,5 +9,4 @@ test('create a repository', async ({page}) => {
await page.locator('input[name="repo_name"]').fill(repoName);
await page.getByRole('button', {name: 'Create Repository'}).click();
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}$`));
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
});
@@ -1,20 +1,16 @@
import {test, expect} from '@playwright/test';
import {loginUser, apiCreateUser, apiDeleteUser, randomString} from './utils.ts';
import {loginUser, apiCreateUser, randomString} from './utils.ts';
test('update profile biography', async ({page, request}) => {
const username = `e2e-settings-${randomString(8)}`;
const bio = `e2e-bio-${randomString(8)}`;
await apiCreateUser(request, username);
try {
await loginUser(page, username);
await page.goto('/user/settings');
await page.getByLabel('Biography').fill(bio);
await page.getByRole('button', {name: 'Update Profile'}).click();
await expect(page.getByLabel('Biography')).toHaveValue(bio);
await page.getByLabel('Biography').fill('');
await page.getByRole('button', {name: 'Update Profile'}).click();
await expect(page.getByLabel('Biography')).toHaveValue('');
} finally {
await apiDeleteUser(request, username);
}
await loginUser(page, username);
await page.goto('/user/settings');
await page.getByLabel('Biography').fill(bio);
await page.getByRole('button', {name: 'Update Profile'}).click();
await expect(page.getByLabel('Biography')).toHaveValue(bio);
await page.getByLabel('Biography').fill('');
await page.getByRole('button', {name: 'Update Profile'}).click();
await expect(page.getByLabel('Biography')).toHaveValue('');
});
+106 -12
View File
@@ -47,11 +47,18 @@ export async function apiCreateRepo(requestContext: APIRequestContext, {name, au
}), 'apiCreateRepo');
}
export async function apiCreateIssue(requestContext: APIRequestContext, owner: string, repo: string, {title, headers}: {title: string; headers?: Record<string, string>}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
export async function apiCreateOrg(requestContext: APIRequestContext, name: string, {headers}: {headers?: Record<string, string>} = {}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/orgs`, {
headers: headers || apiHeaders(),
data: {title},
}), 'apiCreateIssue');
data: {username: name},
}), 'apiCreateOrg');
}
export async function apiCreateTeam(requestContext: APIRequestContext, org: string, name: string, {permission = 'read', units = ['repo.code'], headers}: {permission?: string; units?: Array<string>; headers?: Record<string, string>} = {}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/orgs/${org}/teams`, {
headers: headers || apiHeaders(),
data: {name, permission, units},
}), 'apiCreateTeam');
}
export async function apiStartStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
@@ -60,10 +67,10 @@ export async function apiStartStopwatch(requestContext: APIRequestContext, owner
}), 'apiStartStopwatch');
}
export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string) {
export async function apiCreateFile(requestContext: APIRequestContext, owner: string, repo: string, filepath: string, content: string, {branch, newBranch, message}: {branch?: string; newBranch?: string; message?: string} = {}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/contents/${filepath}`, {
headers: apiHeaders(),
data: {content: globalThis.btoa(content)},
data: {content: Buffer.from(content, 'utf8').toString('base64'), branch, new_branch: newBranch, message},
}), 'apiCreateFile');
}
@@ -74,6 +81,35 @@ export async function apiCreateBranch(requestContext: APIRequestContext, owner:
}), 'apiCreateBranch');
}
/** Create a PR via API. Returns the PR index for subsequent operations. */
export async function apiCreatePR(requestContext: APIRequestContext, owner: string, repo: string, head: string, base: string, title: string, {headers}: {headers?: Record<string, string>} = {}): Promise<number> {
let prIndex = 0;
await apiRetry(async () => {
const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls`, {
headers: headers || apiHeaders(),
data: {head, base, title},
});
if (response.ok()) prIndex = (await response.json()).number;
return response;
}, 'apiCreatePR');
return prIndex;
}
/** Create a review on a PR. `event: "COMMENT"` submits immediately without a pending review. */
export async function apiCreateReview(requestContext: APIRequestContext, owner: string, repo: string, index: number, {event = 'COMMENT', body, comments = [], headers}: {event?: string; body?: string; comments?: Array<{path: string; body: string; new_position?: number; old_position?: number}>; headers?: Record<string, string>} = {}) {
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, {
headers: headers || apiHeaders(),
data: {event, body, comments},
}), 'apiCreateReview');
}
export async function createProjectColumn(requestContext: APIRequestContext, owner: string, repo: string, projectID: string, title: string) {
await apiRetry(() => requestContext.post(`${baseUrl()}/${owner}/${repo}/projects/${projectID}/columns/new`, {
headers: apiHeaders(),
form: {title},
}), 'createProjectColumn');
}
export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) {
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, {
headers: apiHeaders(),
@@ -87,7 +123,7 @@ export async function apiDeleteOrg(requestContext: APIRequestContext, name: stri
}
/** Password shared by all test users — used for both API user creation and browser login. */
const testUserPassword = 'e2e-password!aA1';
export const testUserPassword = 'e2e-password!aA1';
export function apiUserHeaders(username: string) {
return apiAuthHeader(username, testUserPassword);
@@ -106,16 +142,74 @@ export async function apiDeleteUser(requestContext: APIRequestContext, username:
}), 'apiDeleteUser');
}
export async function createProject(
page: Page,
{owner, repo, title}: {owner: string; repo: string; title: string},
): Promise<{id: number}> {
// Navigate to new project page
await page.goto(`/${owner}/${repo}/projects/new`);
// Fill in project details
await page.getByLabel('Title').fill(title);
// Submit the form
await page.getByRole('button', {name: 'Create Project'}).click();
// Wait for redirect to projects list
await page.waitForURL(new RegExp(`/${owner}/${repo}/projects$`));
// Extract the project ID from the project link in the list
const projectLink = page.locator('.milestone-list > .item').filter({hasText: title}).locator('a').first();
const href = await projectLink.getAttribute('href');
const match = /\/projects\/(\d+)/.exec(href || '');
const id = match ? parseInt(match[1]) : 0;
return {id};
}
export async function apiCreateIssue(
requestContext: APIRequestContext,
{owner, repo, title, body, projects, headers}: {
owner: string;
repo: string;
title: string;
body?: string;
projects?: number[];
headers?: Record<string, string>;
},
): Promise<{index: number}> {
let result: {index: number} = {index: 0};
await apiRetry(async () => {
const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues`, {
headers: headers || apiHeaders(),
data: {title, body: body || '', projects: projects || []},
});
if (response.ok()) {
const json = await response.json();
// API returns "number" field for the issue index
result = {index: json.number};
}
return response;
}, 'apiCreateIssue');
return result;
}
export async function clickDropdownItem(page: Page, trigger: Locator, itemText: string) {
await trigger.click();
await page.getByText(itemText).click();
}
export async function loginUser(page: Page, username: string) {
return login(page, username, testUserPassword);
}
export async function login(page: Page, username = env.GITEA_TEST_E2E_USER, password = env.GITEA_TEST_E2E_PASSWORD) {
await page.goto('/user/login');
await page.getByLabel('Username or Email Address').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', {name: 'Sign In'}).click();
await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden();
const response = await page.request.post('/user/login', {
form: {user_name: username, password},
maxRedirects: 0,
});
const status = response.status();
if (status !== 302 && status !== 303) throw new Error(`login as ${username} failed: HTTP ${status}`);
}
export async function assertNoJsError(page: Page) {
@@ -0,0 +1,67 @@
import {test, expect, type Page} from '@playwright/test';
import {apiCreateUser, loginUser, randomString, testUserPassword} from './utils.ts';
const signedIn = /^(?!.*\/user\/(login|webauthn))/; // the target of a finished login varies
async function registerKey(page: Page, nickname: string) {
await page.goto('/user/settings/security');
await page.getByLabel('Nickname').fill(nickname);
await page.getByRole('button', {name: 'Add Security Key'}).click();
}
async function signInWithPassword(page: Page, username: string) {
await page.context().clearCookies();
await page.goto('/user/login');
await page.getByLabel('Username or Email Address').fill(username);
await page.getByLabel('Password').fill(testUserPassword);
await page.getByRole('button', {name: 'Sign In'}).click();
}
// regression: credProtect level 3 hid the credential from the second-factor login
test('security key survives credProtect', async ({page, request, browserName}) => {
test.skip(browserName !== 'chromium', 'only the CDP authenticator emulates credProtect'); // eslint-disable-line playwright/no-skipped-test
const username = `e2e-credprotect-${randomString(8)}`;
await apiCreateUser(request, username);
const cdp = await page.context().newCDPSession(page);
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', {options: {
protocol: 'ctap2',
ctap2Version: 'ctap2_1',
transport: 'usb',
hasResidentKey: true,
hasUserVerification: true,
hasCredBlob: true, // CDP only emulates credProtect together with credBlob
isUserVerified: true,
}});
await loginUser(page, username);
await registerKey(page, 'e2e-key');
await expect(page.getByText('e2e-key')).toBeVisible();
await registerKey(page, 'e2e-key-again');
await expect(page.locator('#webauthn-error-msg')).toContainText('already registered');
await signInWithPassword(page, username);
await expect(page).toHaveURL(signedIn);
});
// this authenticator has no credProtect, so it cannot replace the test above
test('security key signs in as second factor and as passkey', async ({page, request}) => {
const username = `e2e-passkey-${randomString(8)}`;
await apiCreateUser(request, username);
await page.context().credentials.install();
await loginUser(page, username);
await registerKey(page, 'e2e-key');
await expect(page.getByText('e2e-key')).toBeVisible();
await signInWithPassword(page, username);
await expect(page).toHaveURL(signedIn);
await page.context().clearCookies();
await page.goto('/user/login');
await page.getByText('Sign in with a passkey').click();
await expect(page).toHaveURL(signedIn);
});
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"io"
"testing"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
)
func newFuzzRenderContext() *markup.RenderContext {
@@ -3,6 +3,6 @@
filemode = true
bare = true
[remote "origin"]
url = /home/mura/go/src/code.gitea.io/gitea/tests/gitea-repositories-meta/user2/commits_search_test/
url = /home/mura/go/src/gitea.dev/tests/gitea-repositories-meta/user2/commits_search_test/
fetch = +refs/*:refs/*
mirror = true
@@ -1,106 +0,0 @@
# Integration tests
Integration tests can be run with make commands for the
appropriate backends, namely:
```shell
make test-sqlite
make test-pgsql
make test-mysql
make test-mssql
```
Make sure to perform a clean build before running tests:
```
make clean build
```
## Run tests via local act_runner
### Run all jobs
```
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
```
Warning: This file defines many jobs, so it will be resource-intensive and therefor not recommended.
### Run single job
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -j <job_name>
```
You can list all job names via:
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -l
```
## Run sqlite integration tests
Start tests
```
make test-sqlite
```
## Run MySQL integration tests
Setup a MySQL database inside docker
```
docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container)
docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container)
```
Start tests based on the database container
```
TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-mysql
```
## Run pgsql integration tests
Setup a pgsql database inside docker
```
docker run -e "POSTGRES_DB=test" -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
```
Setup minio inside docker
```
docker run --rm -p 9000:9000 -e MINIO_ROOT_USER=123456 -e MINIO_ROOT_PASSWORD=12345678 --name minio bitnamilegacy/minio:2023.8.31
```
Start tests based on the database container
```
TEST_MINIO_ENDPOINT=localhost:9000 TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=postgres TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
```
## Run mssql integration tests
Setup a mssql database inside docker
```
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container)
```
Start tests based on the database container
```
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql
```
## Running individual tests
Example command to run GPG test:
For SQLite:
```
make test-sqlite#GPG
```
For other databases(replace `mssql` to `mysql`, or `pgsql`):
```
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql#GPG
```
## Setting timeouts for declaring long-tests and long-flushes
We appreciate that some testing machines may not be very powerful and
the default timeouts for declaring a slow test or a slow clean-up flush
may not be appropriate.
You can set the following environment variables:
```bash
GITEA_TEST_SLOW_RUN="10s" GITEA_TEST_SLOW_FLUSH="1s" make test-sqlite
```
@@ -1,91 +0,0 @@
# 关于集成测试
使用如下 make 命令可以运行指定的集成测试:
```shell
make test-mysql
make test-pgsql
make test-sqlite
```
在执行集成测试命令前请确保清理了之前的构建环境,清理命令如下:
```
make clean build
```
## 如何在本地 act_runner 上运行测试
### 运行所有任务
```
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
```
警告:由于在此文件中定义了许多任务,因此此操作将花费太多的CPU和内存来运行。所以不建议这样做。
### 运行单个任务
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -j <job_name>
```
您可以通过以下方式列出所有任务名称:
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -l
```
## 如何使用 sqlite 数据库进行集成测试
使用该命令执行集成测试
```
make test-sqlite
```
## 如何使用 mysql 数据库进行集成测试
首先在docker容器里部署一个 mysql 数据库
```
docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:8 #(just ctrl-c to stop db and clean the container)
```
之后便可以基于这个数据库进行集成测试
```
TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-mysql
```
## 如何使用 pgsql 数据库进行集成测试
同上,首先在 docker 容器里部署一个 pgsql 数据库
```
docker run -e "POSTGRES_DB=test" -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
```
在docker内设置minio
```
docker run --rm -p 9000:9000 -e MINIO_ROOT_USER=123456 -e MINIO_ROOT_PASSWORD=12345678 --name minio bitnamilegacy/minio:2023.8.31
```
之后便可以基于这个数据库进行集成测试
```
TEST_MINIO_ENDPOINT=localhost:9000 TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=postgres TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
```
## Run mssql integration tests
同上,首先在 docker 容器里部署一个 mssql 数据库
```
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container)
```
之后便可以基于这个数据库进行集成测试
```
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql
```
## 如何进行自定义的集成测试
下面的示例展示了怎样在集成测试中只进行 GPG 测试:
sqlite 数据库:
```
make test-sqlite#GPG
```
其它数据库(把 MSSQL 替换为 MYSQL, PGSQL):
```
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql#GPG
```
@@ -0,0 +1,3 @@
Content-Type: application/json
{"id":1,"name":"test_repo","full_name":"gitea/test_repo","owner":{"id":1,"login":"gitea","username":"gitea"},"private":false,"html_url":"https://gitea.com/gitea/test_repo","clone_url":"https://gitea.com/gitea/test_repo.git","default_branch":"master","description":"test repo for migration"}
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"id":1553,"body":"TESTSET for gitea2gitea migration\n","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"},{"id":1554,"body":"Oh!\n","user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"content":"gitea"},{"user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"content":"laugh"}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"number":1,"title":"issue1","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":2,"title":"issue2","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":3,"title":"issue3","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":4,"title":"what is this repo about?","body":"","state":"closed","user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"labels":[{"id":2,"name":"Question","color":"#d876e3"}],"milestone":{"id":1,"title":"V1"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","closed_at":"2020-01-01T00:00:00Z","is_locked":true},{"number":5,"title":"issue5","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":6,"title":"issue6","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":7,"title":"issue7","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"id":1,"name":"Bug","color":"#ee0701","description":""},{"id":2,"name":"Question","color":"#d876e3","description":""}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"id":1,"title":"V1","description":"first milestone","state":"closed","created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","closed_at":"2020-01-01T00:00:00Z"},{"id":2,"title":"V2 Finalize","description":"second milestone","state":"open","created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"id":1,"tag_name":"V1","target_commitish":"master","name":"First Release","body":"as title","draft":false,"prerelease":false,"created_at":"2020-01-01T00:00:00Z","published_at":"2020-01-01T00:00:00Z","author":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"assets":[]},{"id":2,"tag_name":"v2-rc1","target_commitish":"master","name":"Second Release","body":"this repo has:\n- issues\n- pulls","draft":false,"prerelease":true,"created_at":"2020-01-01T00:00:00Z","published_at":"2020-01-01T00:00:00Z","author":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"assets":[]}]
@@ -0,0 +1,3 @@
Content-Type: application/json
{"max_response_items":50,"default_paging_num":30,"default_git_trees_per_page":40,"default_max_blob_size":10485760}
@@ -0,0 +1,3 @@
Content-Type: application/json
{"version":"1.22.0"}
@@ -11,12 +11,12 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
)
@@ -70,8 +70,7 @@ jobs:
Name: new("approve-all-runs-fork"),
}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
var apiForkRepo api.Repository
DecodeJSON(t, resp, &apiForkRepo)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user4APICtx)(t)
@@ -140,3 +139,107 @@ jobs:
assert.Equal(t, actions_model.StatusWaiting, run2.Status)
})
}
// TestForkPullRequestApprovalNotBypassedByPriorApproval verifies that a single
// approval on a fork PR does not permanently trust the contributor: a subsequent
// fork PR from the same user must still be gated (Blocked / NeedApproval=true)
// until that user has had a pull request merged in the repo.
func TestForkPullRequestApprovalNotBypassedByPriorApproval(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
user4Session := loginUser(t, user4.Name)
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-approval-regression", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user2APICtx)(t)
wfTreePath := ".gitea/workflows/ci.yml"
wfContent := `name: CI
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo ok
`
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath,
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "add ci", wfContent))
// user4 forks the repo
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
&api.CreateForkOption{Name: new("fork-approval-regression-fork")}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user4APICtx)(t)
// PR #1: a benign change from user4's fork — first-time contributor, gate engages.
doAPICreateFile(user4APICtx, "first.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "first",
Message: "first",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("first")),
})(t)
pr1, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":first")(t)
assert.NoError(t, err)
run1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr1.Index)})
assert.True(t, run1.NeedApproval, "first fork PR must require approval")
assert.Equal(t, actions_model.StatusBlocked, run1.Status)
// user2 approves run1.
req = NewRequest(t, "POST", fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", baseRepo.Link(), pr1.Head.Sha))
user2Session.MakeRequest(t, req, http.StatusOK)
run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run1.ID})
assert.False(t, run1.NeedApproval)
assert.Equal(t, user2.ID, run1.ApprovedBy)
// PR #2: same user, fresh branch. Pre-fix, this run was created with
// NeedApproval=false and dispatched immediately — the bypass path.
doAPICreateFile(user4APICtx, "second.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "second",
Message: "second",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("second")),
})(t)
pr2, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":second")(t)
assert.NoError(t, err)
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr2.Index)})
assert.True(t, run2.NeedApproval, "second fork PR must still require approval — prior approval-to-run does not grant trust")
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
assert.EqualValues(t, 0, run2.ApprovedBy)
// After merging PR #1, user4 becomes a known contributor and the gate lifts for a new PR.
doAPIMergePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, pr1.Index)(t)
doAPICreateFile(user4APICtx, "third.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "third",
Message: "third",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("third")),
})(t)
pr3, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":third")(t)
assert.NoError(t, err)
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
assert.False(t, run3.NeedApproval, "fork PR from a user with a prior merged PR should not require approval")
})
}
@@ -11,19 +11,22 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
webhook_module "code.gitea.io/gitea/modules/webhook"
actions_service "code.gitea.io/gitea/services/actions"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
webhook_module "gitea.dev/modules/webhook"
actions_web "gitea.dev/routers/web/repo/actions"
actions_service "gitea.dev/services/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowConcurrency(t *testing.T) {
@@ -96,7 +99,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -109,7 +112,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -125,7 +128,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -201,7 +204,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -214,7 +217,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -230,7 +233,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -318,7 +321,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -331,7 +334,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -347,7 +350,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -412,8 +415,8 @@ jobs:
doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "bugfix/aaa")(t)
pr1Task1 := runner.fetchTask(t)
_, _, pr1Run1 := getTaskAndJobAndRunByTaskID(t, pr1Task1.Id)
assert.Equal(t, "pull-request-test", pr1Run1.ConcurrencyGroup)
assert.True(t, pr1Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr1Run1))
assert.True(t, getRunConcurrencyCancel(t, pr1Run1))
assert.Equal(t, actions_model.StatusRunning, pr1Run1.Status)
// user4 forks the repo
@@ -422,8 +425,7 @@ jobs:
Name: new("actions-concurrency-fork"),
}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
var apiForkRepo api.Repository
DecodeJSON(t, resp, &apiForkRepo)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user4APICtx)(t)
@@ -458,8 +460,8 @@ jobs:
// fetch the task and the previous task has been cancelled
pr2Task1 := runner.fetchTask(t)
_, _, pr2Run1 = getTaskAndJobAndRunByTaskID(t, pr2Task1.Id)
assert.Equal(t, "pull-request-test", pr2Run1.ConcurrencyGroup)
assert.True(t, pr2Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr2Run1))
assert.True(t, getRunConcurrencyCancel(t, pr2Run1))
assert.Equal(t, actions_model.StatusRunning, pr2Run1.Status)
pr1Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr1Run1.ID})
assert.Equal(t, actions_model.StatusCancelled, pr1Run1.Status)
@@ -484,19 +486,23 @@ jobs:
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user4-fix2")),
})(t)
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
// cannot fetch the task because cancel-in-progress is false
pr3, _ := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
// cannot fetch the task: approval still required (user4 has no merged PR) and cancel-in-progress is false
runner.fetchNoTask(t)
runner.execTask(t, pr2Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
pr2Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr2Run1.ID})
assert.Equal(t, actions_model.StatusSuccess, pr2Run1.Status)
// user2 approves the third PR's run (user4 still has no merged PR, approval still required)
pr3Run1Pending := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, pr3Run1Pending.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
// fetch the task
pr3Task1 := runner.fetchTask(t)
_, _, pr3Run1 := getTaskAndJobAndRunByTaskID(t, pr3Task1.Id)
assert.Equal(t, "pull-request-test", pr3Run1.ConcurrencyGroup)
assert.False(t, pr3Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr3Run1))
assert.False(t, getRunConcurrencyCancel(t, pr3Run1))
assert.Equal(t, actions_model.StatusRunning, pr3Run1.Status)
})
}
@@ -643,6 +649,7 @@ jobs:
assert.Equal(t, "job-main-v1.24.0", wf2Job2Rerun1Job.ConcurrencyGroup)
// rerun wf2-job2
wf2Job2ActionJob = getLatestAttemptJobByTemplateJobID(t, wf2Run.ID, wf2Job2ActionJob.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, wf2Run.ID, wf2Job2ActionJob.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
// (rerun2) fetch and exec wf2-job2
@@ -803,7 +810,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
// run the workflow with appVersion=v1.22 and cancel=false
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -813,7 +820,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -832,7 +839,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
})
@@ -893,7 +900,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
@@ -902,7 +909,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -927,7 +934,7 @@ jobs:
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, actions_model.StatusRunning, run4.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
@@ -945,7 +952,7 @@ jobs:
task5 := runner.fetchTask(t)
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
assert.Equal(t, run4.ID, run4_1.ID)
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
@@ -969,7 +976,7 @@ jobs:
_, _, run3_2 := getTaskAndJobAndRunByTaskID(t, task6.Id)
assert.Equal(t, run3.ID, run3_2.ID)
assert.Equal(t, actions_model.StatusRunning, run3_2.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
@@ -1031,7 +1038,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
@@ -1040,7 +1047,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -1065,7 +1072,7 @@ jobs:
task4 := runner.fetchTask(t)
_, job4, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, actions_model.StatusRunning, run4.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
@@ -1074,15 +1081,17 @@ jobs:
})
// rerun cancel true scenario
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
job4 = getLatestAttemptJobByTemplateJobID(t, run4.ID, job4.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run4.ID, job4.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
task5 := runner.fetchTask(t)
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
assert.Equal(t, run4.ID, run4_1.ID)
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
@@ -1093,18 +1102,20 @@ jobs:
// rerun cancel false scenario
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
run2_2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2.ID})
assert.Equal(t, actions_model.StatusWaiting, run2_2.Status)
job3 = getLatestAttemptJobByTemplateJobID(t, run3.ID, job3.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run3.ID, job3.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
task6 := runner.fetchTask(t)
_, _, run3 = getTaskAndJobAndRunByTaskID(t, task6.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
@@ -1147,8 +1158,8 @@ jobs:
// fetch the task triggered by push
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "schedule-concurrency", run1.ConcurrencyGroup)
assert.True(t, run1.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run1))
assert.True(t, getRunConcurrencyCancel(t, run1))
assert.Equal(t, string(webhook_module.HookEventPush), run1.TriggerEvent)
assert.Equal(t, actions_model.StatusRunning, run1.Status)
@@ -1165,8 +1176,8 @@ jobs:
assert.Equal(t, actions_model.StatusSuccess, run1.Status)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "schedule-concurrency", run2.ConcurrencyGroup)
assert.False(t, run2.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run2))
assert.False(t, getRunConcurrencyCancel(t, run2))
assert.Equal(t, string(webhook_module.HookEventSchedule), run2.TriggerEvent)
assert.Equal(t, actions_model.StatusRunning, run2.Status)
@@ -1177,8 +1188,8 @@ jobs:
assert.NoError(t, actions_service.StartScheduleTasks(t.Context()))
runner.fetchNoTask(t) // cannot fetch because task2 is not completed
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Status: actions_model.StatusBlocked})
assert.Equal(t, "schedule-concurrency", run3.ConcurrencyGroup)
assert.False(t, run3.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run3))
assert.False(t, getRunConcurrencyCancel(t, run3))
assert.Equal(t, string(webhook_module.HookEventSchedule), run3.TriggerEvent)
// trigger the task by push
@@ -1204,8 +1215,8 @@ jobs:
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, "schedule-concurrency", run4.ConcurrencyGroup)
assert.True(t, run4.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run4))
assert.True(t, getRunConcurrencyCancel(t, run4))
assert.Equal(t, string(webhook_module.HookEventPush), run4.TriggerEvent)
run3 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run3.ID})
assert.Equal(t, actions_model.StatusCancelled, run3.Status)
@@ -1317,7 +1328,7 @@ jobs:
w1j2Task := runner2.fetchTask(t)
_, w1j1Job, w1Run := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
assert.Equal(t, "job-group-1", w1j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", w1Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w1Run))
assert.Equal(t, "concurrent-workflow-1.yml", w1Run.WorkflowID)
assert.Equal(t, actions_model.StatusRunning, w1j1Job.Status)
_, w1j2Job, _ := getTaskAndJobAndRunByTaskID(t, w1j2Task.Id)
@@ -1358,7 +1369,7 @@ jobs:
w3j1Task := runner1.fetchTask(t)
_, w3j1Job, w3Run = getTaskAndJobAndRunByTaskID(t, w3j1Task.Id)
assert.Equal(t, "job-group-1", w3j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", w3Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w3Run))
assert.Equal(t, "concurrent-workflow-3.yml", w3Run.WorkflowID)
// exec wf1-job2
@@ -1370,7 +1381,7 @@ jobs:
w2j2Task := runner2.fetchTask(t)
_, w2j2Job, w2Run := getTaskAndJobAndRunByTaskID(t, w2j2Task.Id)
assert.Equal(t, "job-group-2", w2j2Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", w2Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w2Run))
assert.Equal(t, "concurrent-workflow-2.yml", w2Run.WorkflowID)
assert.Equal(t, actions_model.StatusRunning, w2j2Job.Status)
@@ -1397,7 +1408,7 @@ jobs:
assert.Equal(t, actions_model.StatusCancelled, w2Run.Status)
_, w4j1Job, w4Run := getTaskAndJobAndRunByTaskID(t, w4j1Task.Id)
assert.Equal(t, "job-group-2", w4j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", w4Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w4Run))
assert.Equal(t, "concurrent-workflow-4.yml", w4Run.WorkflowID)
})
}
@@ -1435,8 +1446,8 @@ jobs:
// fetch and check the first task
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "cancel-run-group", run1.ConcurrencyGroup)
assert.False(t, run1.ConcurrencyCancel)
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run1))
assert.False(t, getRunConcurrencyCancel(t, run1))
assert.Equal(t, actions_model.StatusRunning, run1.Status)
// push another file to trigger the workflow again
@@ -1473,8 +1484,8 @@ jobs:
// fetch and check the second task
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "cancel-run-group", run2.ConcurrencyGroup)
assert.False(t, run2.ConcurrencyCancel)
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run2))
assert.False(t, getRunConcurrencyCancel(t, run2))
assert.Equal(t, actions_model.StatusRunning, run2.Status)
})
}
@@ -1533,7 +1544,7 @@ jobs:
// fetch wf1-job1
w1j1Task := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
assert.Equal(t, "test-group", run1.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run1))
assert.Equal(t, actions_model.StatusRunning, run1.Status)
// query wf1-job2 from db and check its status
w1j2Job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run1.ID, JobID: "wf1-job2"})
@@ -1553,6 +1564,9 @@ jobs:
// run2 is blocked because it is blocked by workflow1's concurrency group "test-group"
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
// complete wf1-job1
runner.execTask(t, w1j1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// mock time
fakeNow := now.Add(setting.Actions.AbandonedJobTimeout)
timeutil.MockSet(fakeNow)
@@ -1571,7 +1585,7 @@ jobs:
// fetch wf2-job1 and check
w2j1Task := runner.fetchTask(t)
_, w2j1Job, run2 := getTaskAndJobAndRunByTaskID(t, w2j1Task.Id)
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
assert.Equal(t, "wf2-job1", w2j1Job.JobID)
assert.Equal(t, actions_model.StatusRunning, run2.Status)
assert.Equal(t, actions_model.StatusRunning, w2j1Job.Status)
@@ -1650,7 +1664,7 @@ jobs:
// cannot fetch run2 because run1 is still running
runner.fetchNoTask(t)
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, WorkflowID: "concurrent-workflow-2.yml"})
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
// exec run1
@@ -1677,3 +1691,164 @@ jobs:
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
})
}
// TestCancelLegacyRunBlockedByConcurrency simulates a workflow run created before migration v331:
// it has no ActionRunAttempt record (LatestAttemptID == 0) and was blocked by workflow-level concurrency.
// Migration v331 drops action_run.concurrency_group / concurrency_cancel, so the run ends up "stuck" with no way for the job emitter to naturally unblock it.
// The test verifies the user can still:
// 1. view the stuck legacy run correctly (web view renders)
// 2. cancel it from the UI, which transitions the run and all its jobs to Cancelled
// 3. rerun the (now cancelled) legacy run successfully
func TestCancelLegacyRunBlockedByConcurrency(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-legacy-concurrency", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
// Manually insert a "legacy" run blocked by workflow-level concurrency: no ActionRunAttempt, LatestAttemptID=0.
// Its workflow-level concurrency info would have been stored on action_run.concurrency_group pre-v331;
// after the migration that column is gone, so we simply mark the run (and its jobs) as Blocked.
legacyWfContent := `name: legacy-blocked
on:
workflow_dispatch:
concurrency:
group: test-group
jobs:
legacy-job1:
runs-on: ubuntu-latest
steps:
- run: echo 'legacy-job1'
legacy-job2:
runs-on: ubuntu-latest
steps:
- run: echo 'legacy-job2'
`
payloads := mustParseSingleWorkflowPayloads(t, legacyWfContent)
now := timeutil.TimeStamp(time.Now().Unix())
legacyRun := &actions_model.ActionRun{
Title: "legacy blocked run",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "legacy-blocked.yml",
Index: 1,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: "0000000000000000000000000000000000000000",
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: actions_model.StatusBlocked,
Created: now - 1,
Updated: now - 1,
}
require.NoError(t, db.Insert(t.Context(), legacyRun))
legacyJob1 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["legacy-job1"].name,
Attempt: 1,
WorkflowPayload: payloads["legacy-job1"].payload,
JobID: "legacy-job1",
Needs: payloads["legacy-job1"].needs,
RunsOn: payloads["legacy-job1"].runsOn,
Status: actions_model.StatusBlocked,
RunAttemptID: 0,
AttemptJobID: 0,
}
legacyJob2 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["legacy-job2"].name,
Attempt: 1,
WorkflowPayload: payloads["legacy-job2"].payload,
JobID: "legacy-job2",
Needs: payloads["legacy-job2"].needs,
RunsOn: payloads["legacy-job2"].runsOn,
Status: actions_model.StatusBlocked,
RunAttemptID: 0,
AttemptJobID: 0,
}
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
// 1) User visits the legacy run's web view - it renders without error.
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
resp := session.MakeRequest(t, req, http.StatusOK)
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
// Legacy run has no attempt record, so RunAttempt is 0 and Attempts is empty.
assert.EqualValues(t, 0, viewResp.State.Run.RunAttempt)
assert.Empty(t, viewResp.State.Run.Attempts)
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Status)
assert.False(t, viewResp.State.Run.Done)
// Legacy workflow-level concurrency info is gone (columns dropped by v331), so GetEffectiveConcurrency returns "": the run cannot self-unblock via job_emitter.
afterLoadRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Empty(t, getRunConcurrencyGroup(t, afterLoadRun))
// Still Blocked, not Done, but user should be able to cancel.
assert.True(t, viewResp.State.Run.CanCancel)
assert.False(t, viewResp.State.Run.CanRerun)
if assert.Len(t, viewResp.State.Run.Jobs, 2) {
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[0].Status)
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[1].Status)
}
// 2) User cancels the legacy run to clean it up.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/cancel", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
// Run and all its jobs transition to Cancelled.
cancelledRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledRun.Status)
cancelledJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledJob1.Status)
cancelledJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledJob2.Status)
// 3) User reruns the now-cancelled legacy run.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
rerunRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Positive(t, rerunRun.LatestAttemptID)
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
// Both jobs run successfully on the registered runner.
for range 2 {
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
}
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Equal(t, actions_model.StatusSuccess, finalRun.Status)
})
}
func getRunConcurrencyGroup(t *testing.T, run *actions_model.ActionRun) string {
cg, _, err := run.GetEffectiveConcurrency(t.Context())
assert.NoError(t, err)
return cg
}
func getRunConcurrencyCancel(t *testing.T, run *actions_model.ActionRun) bool {
_, cc, err := run.GetEffectiveConcurrency(t.Context())
assert.NoError(t, err)
return cc
}
func getLatestAttemptJobByTemplateJobID(t *testing.T, runID, templateJobID int64) *actions_model.ActionRunJob {
t.Helper()
templateJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: templateJobID, RunID: runID})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
job, err := actions_model.GetRunJobByAttemptJobID(t.Context(), run.ID, run.LatestAttemptID, templateJob.AttemptJobID)
assert.NoError(t, err)
return job
}
@@ -0,0 +1,118 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"sync"
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// minimalWorkflowPayload returns the minimal YAML for a single-job workflow with no steps.
func minimalConcurrentWorkflowPayload(jobID string) []byte {
return []byte("on: push\njobs:\n " + jobID + ":\n runs-on: ubuntu-latest\n")
}
// TestCreateTaskForRunnerConcurrentClaim verifies that when multiple runners
// poll simultaneously and all initially see the same first waiting job,
// each runner claims a distinct job rather than all but one being left
// empty-handed. This is the regression test for the race condition where
// runners losing the optimistic-lock on job #1 would receive latestVersion
// and never retry the remaining 49+ jobs.
//
// It lives in tests/integration rather than a unit test because SQLite
// serializes write transactions, so the contended optimistic-lock path this
// guards only runs concurrently against MySQL/PostgreSQL in CI.
func TestCreateTaskForRunnerConcurrentClaim(t *testing.T) {
defer tests.PrepareTestEnv(t)()
const numJobs = 3
run := &actions_model.ActionRun{
Title: "concurrent-claim-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 9901,
TriggerUserID: 2,
Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: actions_model.StatusWaiting,
}
require.NoError(t, db.Insert(t.Context(), run))
jobs := make([]*actions_model.ActionRunJob, numJobs)
for i := range numJobs {
jobID := "concurrent-job-" + string(rune('a'+i))
jobs[i] = &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: jobID,
Attempt: 1,
JobID: jobID,
Status: actions_model.StatusWaiting,
RunsOn: []string{"ubuntu-latest"},
WorkflowPayload: minimalConcurrentWorkflowPayload(jobID),
}
require.NoError(t, db.Insert(t.Context(), jobs[i]))
}
runners := make([]*actions_model.ActionRunner, numJobs)
for i := range numJobs {
r := &actions_model.ActionRunner{
UUID: "concurrent-runner-uuid-" + string(rune('a'+i)),
Name: "concurrent-runner-" + string(rune('a'+i)),
AgentLabels: []string{"ubuntu-latest"},
}
r.GenerateAndFillToken()
runners[i] = r
require.NoError(t, db.Insert(t.Context(), runners[i]))
}
// Simulate the burst: all runners call CreateTaskForRunner concurrently,
// as happens when all see the same stale tasksVersion simultaneously.
type result struct {
task *actions_model.ActionTask
ok bool
err error
}
results := make([]result, numJobs)
var wg sync.WaitGroup
for i := range numJobs {
wg.Go(func() {
task, ok, err := actions_model.CreateTaskForRunner(t.Context(), runners[i])
results[i] = result{task, ok, err}
})
}
wg.Wait()
// Every runner must have received a task without error.
claimedJobIDs := make(map[int64]bool)
for i, r := range results {
require.NoError(t, r.err, "runner %d got an unexpected error", i)
require.True(t, r.ok, "runner %d did not get a task even though free jobs exist", i)
require.NotNil(t, r.task)
assert.False(t, claimedJobIDs[r.task.JobID], "job %d was claimed by more than one runner", r.task.JobID)
claimedJobIDs[r.task.JobID] = true
}
assert.Len(t, claimedJobIDs, numJobs, "expected %d distinct jobs to be claimed", numJobs)
// All jobs must now be running with a task assigned.
for _, j := range jobs {
updated := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: j.ID})
assert.Equal(t, actions_model.StatusRunning, updated.Status)
assert.NotZero(t, updated.TaskID)
}
}
@@ -11,14 +11,14 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/routers/web/repo/actions"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
"gitea.dev/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -136,7 +136,7 @@ jobs:
runID = run.ID
}
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), runID)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(t.Context(), apiRepo.ID, runID)
assert.NoError(t, err)
for i := 0; i < len(testCase.outcomes); i++ {
@@ -9,11 +9,11 @@ import (
"net/url"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
@@ -13,18 +13,20 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
actions_service "code.gitea.io/gitea/services/actions"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
actions_service "gitea.dev/services/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -157,8 +159,7 @@ jobs:
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", user2.Name, apiRepo.Name)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var actionTaskRespAfter api.ActionTaskResponse
DecodeJSON(t, resp, &actionTaskRespAfter)
actionTaskRespAfter := DecodeJSON(t, resp, &api.ActionTaskResponse{})
for _, apiTask := range actionTaskRespAfter.Entries {
if apiTask.HeadSHA != fileResp.Commit.SHA {
continue
@@ -460,8 +461,7 @@ func getRepoRunnerID(t *testing.T, authToken, ownerName, repoName string) int64
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", ownerName, repoName)).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
runnerList := api.ActionRunnersResponse{}
DecodeJSON(t, resp, &runnerList)
runnerList := DecodeJSON(t, resp, &api.ActionRunnersResponse{})
require.Len(t, runnerList.Entries, 1)
return runnerList.Entries[0].ID
}
@@ -707,9 +707,8 @@ func createActionsTestRepo(t *testing.T, authToken, repoName string, isPrivate b
DefaultBranch: "main",
}).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusCreated)
var apiRepo api.Repository
DecodeJSON(t, resp, &apiRepo)
return &apiRepo
apiRepo := DecodeJSON(t, resp, &api.Repository{})
return apiRepo
}
func getWorkflowCreateFileOptions(u *user_model.User, branch, msg, content string) *api.CreateFileOptions {
@@ -738,9 +737,8 @@ func createWorkflowFile(t *testing.T, authToken, ownerName, repoName, treePath s
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ownerName, repoName, treePath), opts).
AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusCreated)
var fileResponse api.FileResponse
DecodeJSON(t, resp, &fileResponse)
return &fileResponse
fileResponse := DecodeJSON(t, resp, &api.FileResponse{})
return fileResponse
}
// getTaskJobNameByTaskID get the job name of the task by task ID
@@ -750,8 +748,7 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", ownerName, repoName)).
AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
var taskRespBefore api.ActionTaskResponse
DecodeJSON(t, resp, &taskRespBefore)
taskRespBefore := DecodeJSON(t, resp, &api.ActionTaskResponse{})
for _, apiTask := range taskRespBefore.Entries {
if apiTask.ID == taskID {
return apiTask.Name
@@ -759,3 +756,161 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
}
return ""
}
// TestLegacyRunsInCronTasks verifies that the background cron tasks correctly handle runs/jobs
// created before migration v331 (legacy data with LatestAttemptID=0 and jobs with RunAttemptID=0).
func TestLegacyRunsInCronTasks(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-legacy-cron", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
// Far-past timestamp so the queries match regardless of the configured timeouts.
oldTS := timeutil.TimeStamp(time.Now().Add(-30 * 24 * time.Hour).Unix())
// insertLegacyRunJob inserts a run + job without an ActionRunAttempt record, simulating data created before migration v331 (LatestAttemptID=0, job.RunAttemptID=0, job.AttemptJobID=0).
insertLegacyRunJob := func(t *testing.T, index int64, runStatus, jobStatus actions_model.Status) (*actions_model.ActionRun, *actions_model.ActionRunJob) {
t.Helper()
run := &actions_model.ActionRun{
Title: fmt.Sprintf("legacy run %d", index),
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: fmt.Sprintf("legacy-%d.yml", index),
Index: index,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: "0000000000000000000000000000000000000000",
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: runStatus,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
Name: "legacy-job",
Attempt: 1,
JobID: "legacy-job",
RunsOn: []string{"ubuntu-latest"},
Status: jobStatus,
RunAttemptID: 0,
AttemptJobID: 0,
}
require.NoError(t, db.Insert(t.Context(), job))
// backfill timestamps so the cron task queries can match them.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), run.ID)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).Exec("UPDATE action_run_job SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), job.ID)
require.NoError(t, err)
run.Created, run.Updated = oldTS, oldTS
job.Created, job.Updated = oldTS, oldTS
return run, job
}
t.Run("StopZombieTasks", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 10, actions_model.StatusRunning, actions_model.StatusRunning)
task := &actions_model.ActionTask{
JobID: job.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Started: oldTS,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_task SET updated=? WHERE id=?", int64(oldTS), task.ID)
require.NoError(t, err)
job.TaskID = task.ID
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
require.NoError(t, err)
require.NoError(t, actions_service.StopZombieTasks(t.Context()))
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
})
t.Run("StopEndlessTasks", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 20, actions_model.StatusRunning, actions_model.StatusRunning)
task := &actions_model.ActionTask{
JobID: job.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Started: oldTS,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
require.NoError(t, err)
require.NoError(t, actions_service.StopEndlessTasks(t.Context()))
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
})
t.Run("CancelAbandonedJobs", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 30, actions_model.StatusWaiting, actions_model.StatusWaiting)
require.NoError(t, actions_service.CancelAbandonedJobs(t.Context()))
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusCancelled, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusCancelled, gotRun.Status)
})
t.Run("Cleanup", func(t *testing.T) {
run, _ := insertLegacyRunJob(t, 40, actions_model.StatusSuccess, actions_model.StatusSuccess)
expiredArtifact := &actions_model.ActionArtifact{
RunID: run.ID,
RunAttemptID: 0, // legacy artifact
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
StoragePath: fmt.Sprintf("artifacts/legacy-expired-%d.zip", run.ID),
FileSize: 1,
FileCompressedSize: 1,
ContentEncodingOrType: actions_model.ContentTypeZip,
ArtifactPath: "legacy-expired.zip",
ArtifactName: "legacy-expired",
Status: actions_model.ArtifactStatusUploadConfirmed,
ExpiredUnix: oldTS,
}
require.NoError(t, db.Insert(t.Context(), expiredArtifact))
require.NoError(t, actions_service.Cleanup(t.Context()))
gotArtifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: expiredArtifact.ID})
assert.Equal(t, actions_model.ArtifactStatusExpired, gotArtifact.Status)
})
})
}
@@ -12,19 +12,19 @@ import (
"strconv"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
org_model "gitea.dev/models/organization"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/lfs"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -137,7 +137,7 @@ func TestActionsJobTokenPermissiveAccess(t *testing.T) {
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
// prepare task and its token
require.NoError(t, task.GenerateToken())
task.GenerateAndFillToken()
task.Status = actions_model.StatusRunning
task.IsForkPullRequest = tt.isFork
err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request")
@@ -208,8 +208,7 @@ func TestActionsCrossRepoAccess(t *testing.T) {
AutoInit: true,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var repo structs.Repository
DecodeJSON(t, resp, &repo)
repo := DecodeJSON(t, resp, &structs.Repository{})
return repo.ID
}
@@ -309,7 +308,7 @@ func TestActionsJobTokenPermissionsWriteIssue(t *testing.T) {
repoActionsCfg.MaxTokenPermissions = nil
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
require.NoError(t, task.GenerateToken())
task.GenerateAndFillToken()
task.Status = actions_model.StatusRunning
require.NoError(t, actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status"))
@@ -359,7 +358,7 @@ func createActionTask(t *testing.T, repoID int64, isFork bool) *actions_model.Ac
Status: actions_model.StatusRunning,
IsForkPullRequest: isFork,
}
require.NoError(t, task.GenerateToken())
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
return task
}
@@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsListFilters(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
session := loginUser(t, user5.Name)
actionsURL := fmt.Sprintf("/%s/%s/actions", user5.Name, repo.Name)
t.Run("BranchDropdownListsBranches", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
var labels []string
htmlDoc.doc.Find(`[data-test-id="filter-branch"] .menu a.item`).Each(func(_ int, a *goquery.Selection) {
labels = append(labels, strings.TrimSpace(a.Text()))
})
assert.Contains(t, labels, "master")
})
t.Run("FilterByBranch", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL+"?branch=master")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
refs := htmlDoc.doc.Find(".run-list .run-list-ref")
assert.Positive(t, refs.Length(), "filtered run list should not be empty")
refs.Each(func(_ int, sel *goquery.Selection) {
assert.Equal(t, "master", strings.TrimSpace(sel.Text()))
})
})
t.Run("PaginationPreservesFilters", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL+"?branch=master&limit=1")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
pageLinks := htmlDoc.doc.Find(".pagination a[href]")
assert.Positive(t, pageLinks.Length(), "pagination should be rendered")
pageLinks.Each(func(_ int, a *goquery.Selection) {
u, err := url.Parse(a.AttrOr("href", ""))
require.NoError(t, err)
assert.Equal(t, "master", u.Query().Get("branch"), "pagination link must preserve branch filter")
})
})
}
@@ -9,16 +9,16 @@ import (
"os"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/dbfs"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/storage"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/dbfs"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/storage"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -73,5 +73,19 @@ jobs:
_, err = dbfs.Open(t.Context(), actions_module.DBFSPrefix+freshTask.LogFilename)
assert.ErrorIs(t, err, os.ErrNotExist, "DBFS row must be cleaned up after TransferLogs")
// The runner re-sends its final UpdateLog when the response was lost.
// A sealed log must ack the re-send and still reject new appended rows.
t.Run("re-sent finalize is idempotent", func(t *testing.T) {
finalize := &runnerv1.UpdateLogRequest{TaskId: task.Id, Index: 0, Rows: nil, NoMore: true}
resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(finalize))
require.NoError(t, err)
assert.EqualValues(t, 0, resp.Msg.AckIndex)
_, err = runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{
TaskId: task.Id, Index: 0, Rows: []*runnerv1.LogRow{{Content: "late"}}, NoMore: true,
}))
require.Error(t, err, "appending rows past the seal must be rejected")
})
})
}
@@ -11,15 +11,16 @@ import (
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/test"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/test"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -214,5 +215,95 @@ jobs:
resetFunc()
})
}
t.Run("DownloadRerunTaskLogs", func(t *testing.T) {
treePath := ".gitea/workflows/download-rerun-logs.yml"
fileContent := `name: download-rerun-logs
on:
push:
paths:
- '.gitea/workflows/download-rerun-logs.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo job2
`
// create the workflow file
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+treePath, fileContent)
createWorkflowFile(t, token, user2.Name, repo.Name, treePath, opts)
// first run
job1Task1 := runner.fetchTask(t)
_, job1, _ := getTaskAndJobAndRunByTaskID(t, job1Task1.Id)
runner.execTask(t, job1Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job1 first run",
},
},
})
job2Task1 := runner.fetchTask(t)
_, job2, run := getTaskAndJobAndRunByTaskID(t, job2Task1.Id)
runner.execTask(t, job2Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job2 first run",
},
},
})
// check job1 log
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1.ID)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job1 first run")
// check job2 log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job2 first run")
// only rerun job2
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
session.MakeRequest(t, req, http.StatusOK)
job2TaskRerun := runner.fetchTask(t)
runner.execTask(t, job2TaskRerun, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job2 rerun",
},
},
})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
job1Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
assert.Equal(t, run.LatestAttemptID, job1Rerun.RunAttemptID)
job2Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, run.LatestAttemptID, job2Rerun.RunAttemptID)
// check job1 rerun log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1Rerun.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job1 first run") // should return the log of first run because job1 didn't rerun
// check job2 rerun log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2Rerun.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job2 rerun")
})
})
}
@@ -7,18 +7,33 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
actions_web "gitea.dev/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsRerun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
sessionAdmin := loginUser(t, userAdmin.Name)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
@@ -54,6 +69,7 @@ jobs:
// fetch and exec job1
job1Task := runner.fetchTask(t)
assert.Equal(t, "1", job1Task.Context.GetFields()["run_attempt"].GetStringValue())
_, job1, run := getTaskAndJobAndRunByTaskID(t, job1Task.Id)
runner.execTask(t, job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
@@ -67,45 +83,475 @@ jobs:
runner.execTask(t, job2Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 1, getRunLatestAttemptNum(t, run.ID))
// RERUN-1: rerun the run
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
sessionAdmin.MakeRequest(t, req, http.StatusOK) // triggered by admin user
// fetch and exec job1
job1TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
_, job1R1, _ := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
assert.Equal(t, job1.AttemptJobID, job1R1.AttemptJobID)
runner.execTask(t, job1TaskR1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// fetch and exec job2
job2TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
_, job2R1, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
assert.Equal(t, job2.AttemptJobID, job2R1.AttemptJobID)
runner.execTask(t, job2TaskR1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, run.ID))
// RERUN-2: rerun job1
job1 = getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job1.ID))
session.MakeRequest(t, req, http.StatusOK)
// job2 needs job1, so rerunning job1 will also rerun job2
// fetch and exec job1
job1TaskR2 := runner.fetchTask(t)
assert.Equal(t, "3", job1TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job1TaskR2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// fetch and exec job2
job2TaskR2 := runner.fetchTask(t)
assert.Equal(t, "3", job2TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 3, getRunLatestAttemptNum(t, run.ID))
// RERUN-3: rerun job2
job2 = getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
session.MakeRequest(t, req, http.StatusOK)
// only job2 will rerun
// fetch and exec job2
job2TaskR3 := runner.fetchTask(t)
assert.Equal(t, "4", job2TaskR3.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR3, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
runner.fetchNoTask(t)
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
runLatestAttempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID)
t.Run("RerunFailedWithNoFailedJobs", func(t *testing.T) {
// The run is fully successful, so an empty failed-job list must be rejected rather than fall
// through to re-running the whole run.
before := getRunLatestAttemptNum(t, run.ID)
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", user2.Name, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "no failed jobs")
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
runner.fetchNoTask(t) // no new tasks were scheduled
})
t.Run("RerunFailedWithNoFailedJobsAPI", func(t *testing.T) {
// Same rejection on the API route: an empty failed-job list must not fall through to a full re-run.
before := getRunLatestAttemptNum(t, run.ID)
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun-failed-jobs", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "no failed jobs")
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
runner.fetchNoTask(t)
})
t.Run("AttemptAPI", func(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
attemptResp := MakeRequest(t, req, http.StatusOK)
apiAttempt := DecodeJSON(t, attemptResp, &api.ActionWorkflowRun{})
assert.Equal(t, run.ID, apiAttempt.ID)
assert.EqualValues(t, 2, apiAttempt.RunAttempt)
assert.Equal(t, "completed", apiAttempt.Status)
assert.Equal(t, "success", apiAttempt.Conclusion)
assert.NotNil(t, apiAttempt.PreviousAttemptURL)
assert.True(t, strings.HasSuffix(*apiAttempt.PreviousAttemptURL, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, run.ID)))
assert.Equal(t, user2.Name, apiAttempt.Actor.UserName)
assert.Equal(t, userAdmin.Name, apiAttempt.TriggerActor.UserName)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2/jobs", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
attemptJobsResp := MakeRequest(t, req, http.StatusOK)
apiAttemptJobs := DecodeJSON(t, attemptJobsResp, &api.ActionWorkflowJobsResponse{})
assert.Len(t, apiAttemptJobs.Entries, 2)
assert.ElementsMatch(t, []int64{job1R1.ID, job2R1.ID}, []int64{apiAttemptJobs.Entries[0].ID, apiAttemptJobs.Entries[1].ID})
})
t.Run("MaxRerunAttempts", func(t *testing.T) {
// The run has 4 attempts after the previous reruns. Lower the cap to 4 to hit the limit.
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(4))()
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "workflow run has reached the maximum")
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
// Raising the cap lets rerun proceed again.
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(5))()
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
// fetch and exec job1
job1TaskR4 := runner.fetchTask(t)
assert.Equal(t, "5", job1TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job1TaskR4, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
job2TaskR4 := runner.fetchTask(t)
assert.Equal(t, "5", job2TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR4, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 5, getRunLatestAttemptNum(t, run.ID))
})
})
}
func TestActionsRerunLegacyNoAttemptRun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-rerun-legacy", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
wfTreePath := ".gitea/workflows/actions-rerun-legacy.yml"
wfFileContent := `name: actions-rerun-legacy
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo 'job2'
`
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
fileResp := createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
require.NotNil(t, fileResp)
// Start preparing legacy data
payloads := mustParseSingleWorkflowPayloads(t, wfFileContent)
now := timeutil.TimeStamp(time.Now().Unix())
started := now - 20
stopped := now - 10
legacyRun := &actions_model.ActionRun{
Title: "legacy rerun test",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "actions-rerun-legacy.yml",
Index: 1,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: fileResp.Commit.SHA,
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
Created: started - 5,
Updated: stopped,
}
require.NoError(t, db.Insert(t.Context(), legacyRun))
// xorm does not update "created"-tagged fields via ORM methods; use raw SQL to backfill historical timestamps.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(started-5), int64(stopped), legacyRun.ID)
require.NoError(t, err)
legacyRun.Created = started - 5
legacyRun.Updated = stopped
legacyJob1 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["job1"].name,
Attempt: 1,
WorkflowPayload: payloads["job1"].payload,
JobID: "job1",
Needs: payloads["job1"].needs,
RunsOn: payloads["job1"].runsOn,
Status: actions_model.StatusSuccess,
RunAttemptID: 0,
AttemptJobID: 0,
Started: started,
Stopped: stopped,
IsForkPullRequest: false,
}
legacyJob2 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["job2"].name,
Attempt: 1,
WorkflowPayload: payloads["job2"].payload,
JobID: "job2",
Needs: payloads["job2"].needs,
RunsOn: payloads["job2"].runsOn,
Status: actions_model.StatusSuccess,
RunAttemptID: 0,
AttemptJobID: 0,
Started: started,
Stopped: stopped,
IsForkPullRequest: false,
}
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
legacyTask1 := &actions_model.ActionTask{
JobID: legacyJob1.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
IsForkPullRequest: false,
}
legacyTask1.GenerateAndFillToken()
legacyTask2 := &actions_model.ActionTask{
JobID: legacyJob2.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
IsForkPullRequest: false,
}
legacyTask2.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), legacyTask1, legacyTask2))
legacyJob1.TaskID = legacyTask1.ID
legacyJob2.TaskID = legacyTask2.ID
_, err = db.GetEngine(t.Context()).ID(legacyJob1.ID).Cols("task_id").Update(legacyJob1)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(legacyJob2.ID).Cols("task_id").Update(legacyJob2)
require.NoError(t, err)
legacyArtifact := &actions_model.ActionArtifact{
RunID: legacyRun.ID,
RunAttemptID: 0,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
StoragePath: "artifacts/legacy-artifact.zip",
FileSize: 123,
FileCompressedSize: 123,
ContentEncodingOrType: actions_model.ContentTypeZip,
ArtifactPath: "legacy-artifact.zip",
ArtifactName: "legacy-artifact",
Status: actions_model.ArtifactStatusUploadConfirmed,
ExpiredUnix: now + timeutil.Day,
}
require.NoError(t, db.Insert(t.Context(), legacyArtifact))
// Done preparing legacy data
// assert the web view for the legacy run before rerun
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
legacyResp := session.MakeRequest(t, req, http.StatusOK)
legacyView := DecodeJSON(t, legacyResp, &actions_web.ViewResponse{})
// legacy run has no attempt records, so RunAttempt is 0 and Attempts list is empty
assert.EqualValues(t, 0, legacyView.State.Run.RunAttempt)
assert.Empty(t, legacyView.State.Run.Attempts)
assert.Equal(t, "success", legacyView.State.Run.Status)
assert.True(t, legacyView.State.Run.Done)
// isLatestAttempt=true, done=true: can rerun but not cancel
assert.False(t, legacyView.State.Run.CanCancel)
assert.False(t, legacyView.State.Run.CanApprove)
assert.True(t, legacyView.State.Run.CanRerun)
assert.False(t, legacyView.State.Run.CanRerunFailed) // all jobs succeeded
assert.True(t, legacyView.State.Run.CanDeleteArtifact)
if assert.Len(t, legacyView.State.Run.Jobs, 2) {
assert.Equal(t, legacyJob1.ID, legacyView.State.Run.Jobs[0].ID)
assert.Equal(t, legacyJob2.ID, legacyView.State.Run.Jobs[1].ID)
}
if assert.Len(t, legacyView.Artifacts, 1) {
assert.Equal(t, legacyArtifact.ArtifactName, legacyView.Artifacts[0].Name)
assert.Equal(t, "completed", legacyView.Artifacts[0].Status)
}
// rerun the legacy run
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
runAfterRerun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
jobsAfterRerun, err := actions_model.GetRunJobsByRunAndAttemptID(t.Context(), legacyRun.ID, runAfterRerun.LatestAttemptID)
require.NoError(t, err)
require.Len(t, jobsAfterRerun, 2)
rerunJobsByJobID := map[string]*actions_model.ActionRunJob{}
for _, job := range jobsAfterRerun {
rerunJobsByJobID[job.JobID] = job
}
require.Contains(t, rerunJobsByJobID, "job1")
require.Contains(t, rerunJobsByJobID, "job2")
assert.Equal(t, actions_model.StatusWaiting, rerunJobsByJobID["job1"].Status)
assert.Equal(t, actions_model.StatusBlocked, rerunJobsByJobID["job2"].Status)
// fetch job1 rerun task
job1TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
rerunJob1Task, rerunJob1, rerunRun := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
assert.Equal(t, legacyRun.ID, rerunRun.ID)
assert.Equal(t, rerunJob1.RunAttemptID, rerunRun.LatestAttemptID)
runner.execTask(t, job1TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// fetch job2 rerun task
job2TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
rerunJob2Task, rerunJob2, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
runner.execTask(t, job2TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
runner.fetchNoTask(t)
// query the 2 attempts
runAfterRerun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
attempt1, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 1)
require.NoError(t, err)
assert.Equal(t, legacyRun.Created, attempt1.Created)
assert.Equal(t, legacyRun.Started, attempt1.Started)
assert.Equal(t, legacyRun.Stopped, attempt1.Stopped)
attempt2, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 2)
require.NoError(t, err)
assert.Equal(t, attempt2.ID, runAfterRerun.LatestAttemptID)
assert.Equal(t, runAfterRerun.Created, attempt1.Created)
assert.Equal(t, runAfterRerun.Started, attempt2.Started)
assert.Equal(t, runAfterRerun.Stopped, attempt2.Stopped)
// assert legacy jobs
legacyJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
legacyJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
assert.Equal(t, attempt1.ID, legacyJob1.RunAttemptID)
assert.Equal(t, attempt1.ID, legacyJob2.RunAttemptID)
assert.EqualValues(t, 1, legacyJob1.Attempt)
assert.EqualValues(t, 1, legacyJob2.Attempt)
assert.EqualValues(t, 1, legacyJob1.AttemptJobID)
assert.EqualValues(t, 2, legacyJob2.AttemptJobID)
legacyTask1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask1.ID})
legacyTask2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask2.ID})
assert.EqualValues(t, 1, legacyTask1.Attempt)
assert.EqualValues(t, 1, legacyTask2.Attempt)
// assert legacy artifacts
legacyArtifact = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: legacyArtifact.ID})
assert.Equal(t, attempt1.ID, legacyArtifact.RunAttemptID)
// assert jobs of the latest rerun
rerunJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob1.ID})
rerunJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob2.ID})
assert.Equal(t, attempt2.ID, rerunJob1.RunAttemptID)
assert.Equal(t, attempt2.ID, rerunJob2.RunAttemptID)
assert.Equal(t, legacyJob1.AttemptJobID, rerunJob1.AttemptJobID)
assert.Equal(t, legacyJob2.AttemptJobID, rerunJob2.AttemptJobID)
assert.EqualValues(t, 2, rerunJob1Task.Attempt)
assert.EqualValues(t, 2, rerunJob2Task.Attempt)
// assert the web view for the original attempt
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, legacyRun.ID))
attempt1Resp := session.MakeRequest(t, req, http.StatusOK)
attempt1View := DecodeJSON(t, attempt1Resp, &actions_web.ViewResponse{})
assert.EqualValues(t, 1, attempt1View.State.Run.RunAttempt)
if assert.Len(t, attempt1View.State.Run.Attempts, 2) {
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest), index 1 = attempt #1 (current)
assert.False(t, attempt1View.State.Run.Attempts[0].Current)
assert.True(t, attempt1View.State.Run.Attempts[0].Latest)
assert.True(t, attempt1View.State.Run.Attempts[1].Current)
assert.False(t, attempt1View.State.Run.Attempts[1].Latest)
}
// isLatestAttempt=false: all write operations disabled
assert.False(t, attempt1View.State.Run.CanCancel)
assert.False(t, attempt1View.State.Run.CanApprove)
assert.False(t, attempt1View.State.Run.CanRerun)
assert.False(t, attempt1View.State.Run.CanRerunFailed)
assert.True(t, attempt1View.State.Run.CanDeleteArtifact)
assert.Equal(t, legacyJob1.ID, attempt1View.State.Run.Jobs[0].ID)
assert.Equal(t, legacyJob2.ID, attempt1View.State.Run.Jobs[1].ID)
if assert.Len(t, attempt1View.Artifacts, 1) {
assert.Equal(t, attempt1View.Artifacts[0].Name, legacyArtifact.ArtifactName)
}
// assert the web view for the latest attempt
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
attempt2Resp := session.MakeRequest(t, req, http.StatusOK)
attempt2View := DecodeJSON(t, attempt2Resp, &actions_web.ViewResponse{})
assert.EqualValues(t, 2, attempt2View.State.Run.RunAttempt)
if assert.Len(t, attempt2View.State.Run.Attempts, 2) {
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest, current), index 1 = attempt #1
assert.True(t, attempt2View.State.Run.Attempts[0].Current)
assert.True(t, attempt2View.State.Run.Attempts[0].Latest)
assert.False(t, attempt2View.State.Run.Attempts[1].Current)
assert.False(t, attempt2View.State.Run.Attempts[1].Latest)
}
// isLatestAttempt=true, done=true: can rerun but not cancel
assert.False(t, attempt2View.State.Run.CanCancel)
assert.False(t, attempt2View.State.Run.CanApprove)
assert.True(t, attempt2View.State.Run.CanRerun)
assert.False(t, attempt2View.State.Run.CanRerunFailed) // all jobs succeeded
assert.True(t, attempt2View.State.Run.CanDeleteArtifact)
assert.Equal(t, rerunJob1.ID, attempt2View.State.Run.Jobs[0].ID)
assert.Equal(t, rerunJob2.ID, attempt2View.State.Run.Jobs[1].ID)
assert.Empty(t, attempt2View.Artifacts)
})
}
type workflowJobPayload struct {
name string
payload []byte
needs []string
runsOn []string
}
func mustParseSingleWorkflowPayloads(t *testing.T, workflowContent string) map[string]workflowJobPayload {
t.Helper()
workflows, err := jobparser.Parse([]byte(workflowContent))
require.NoError(t, err)
payloads := make(map[string]workflowJobPayload, len(workflows))
for _, workflow := range workflows {
id, job := workflow.Job()
needs := job.Needs()
require.NoError(t, workflow.SetJob(id, job.EraseNeeds()))
payload, err := workflow.Marshal()
require.NoError(t, err)
payloads[id] = workflowJobPayload{
name: job.Name,
payload: payload,
needs: needs,
runsOn: job.RunsOn(),
}
}
return payloads
}
func getRunLatestAttemptNum(t *testing.T, runID int64) int64 {
t.Helper()
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: run.LatestAttemptID})
return attempt.Attempt
}
@@ -0,0 +1,985 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/json"
"gitea.dev/modules/queue"
api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsReusableWorkflow(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
user4Session := loginUser(t, user4.Name)
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
t.Run("Same-repo reusable workflow", func(t *testing.T) {
apiRepo := createActionsTestRepo(t, user2Token, "workflow-call-test", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
defaultRunner := newMockRunner()
defaultRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-default-runner", []string{"ubuntu-latest"}, false)
customRunner := newMockRunner()
customRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-custom-runner", []string{"custom-os"}, false)
// add a variable for test
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/myvar", repo.OwnerName, repo.Name), &api.CreateVariableOption{
Value: "abcdef",
}).
AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusCreated)
// add a secret for test
req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/mysecret", repo.OwnerName, repo.Name), api.CreateOrUpdateSecretOption{
Data: "secRET-t0Ken",
}).AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusCreated)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable1.yaml",
`name: Reusable1
on:
workflow_call:
inputs:
str_input:
type: string
num_input:
type: number
bool_input:
type: boolean
parent_var:
type: string
needs_out:
type: string
secrets:
PARENT_TOKEN:
outputs:
r1_out:
value: ${{ jobs.reusable1_job2.outputs.r1j2_out }}
jobs:
reusable1_job1:
runs-on: ubuntu-latest
steps:
- run: echo 'reusable1_job1'
reusable1_job2:
needs: [reusable1_job1]
outputs:
r1j2_out: ${{ steps.gen_r1j2_output.outputs.out }}
runs-on: custom-os
steps:
- id: gen_r1j2_output
run: |
echo "out=r1j2_out_data" >> "$GITHUB_OUTPUT"
reusable1_job3:
needs: [reusable1_job2]
uses: ./.gitea/workflows/reusable2.yaml
with:
msg: ${{ inputs.str_input }}
`)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable2.yaml",
`name: Reusable2
on:
workflow_call:
inputs:
msg:
type: string
jobs:
reusable2_job1:
runs-on: ubuntu-latest
steps:
- run: echo ${{ inputs.msg }}
`)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on:
push:
paths:
- '.gitea/workflows/caller.yaml'
jobs:
caller_job1:
runs-on: ubuntu-latest
outputs:
prepared: ${{ steps.gen_output.outputs.pd }}
steps:
- id: gen_output
run: |
echo "pd=prepared_data" >> "$GITHUB_OUTPUT"
caller_job2:
needs: [caller_job1]
uses: './.gitea/workflows/reusable1.yaml'
with:
str_input: 'from_caller_job2'
num_input: ${{ 2.3e2 }}
bool_input: ${{ gitea.event_name == 'push' }}
parent_var: ${{ vars.myvar }}
needs_out: ${{ needs.caller_job1.outputs.prepared }}
secrets:
PARENT_TOKEN: ${{ secrets.mysecret }}
caller_job3:
needs: [caller_job2]
runs-on: ubuntu-latest
steps:
- run: |
echo ${{ needs.caller_job1.outputs.r1_out }}
`)
var (
runID int64
callerJob2ID, callerJob2AttemptJobID int64
callerJob3AttemptJobID int64
r1Job2ID, r1Job2AttemptJobID int64
r1Job3ID, r1Job3AttemptJobID int64
r2Job1AttemptJobID int64
)
t.Run("Check initialized jobs", func(t *testing.T) {
// run
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
runID = run.ID
// caller_job1
assert.Equal(t, 3, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID}))
callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job1"})
assert.Equal(t, actions_model.StatusWaiting, callerJob1.Status)
assert.False(t, callerJob1.IsReusableCaller)
// caller_job2
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job2"})
callerJob2ID = callerJob2.ID
callerJob2AttemptJobID = callerJob2.AttemptJobID
assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status)
assert.True(t, callerJob2.IsReusableCaller)
// caller_job3
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job3"})
callerJob3AttemptJobID = callerJob3.AttemptJobID
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
assert.False(t, callerJob3.IsReusableCaller)
})
t.Run("First run", func(t *testing.T) {
callerJob1Task := defaultRunner.fetchTask(t) // for caller_job1
_, callerJob1, _ := getTaskAndJobAndRunByTaskID(t, callerJob1Task.Id)
assert.Equal(t, "caller_job1", callerJob1.JobID)
defaultRunner.fetchNoTask(t)
defaultRunner.execTask(t, callerJob1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"prepared": "prepared_data",
},
})
r1Job1Task := defaultRunner.fetchTask(t) // for reusable1_job1
_, r1Job1, _ := getTaskAndJobAndRunByTaskID(t, r1Job1Task.Id)
assert.Equal(t, "reusable1_job1", r1Job1.JobID)
assert.Equal(t, callerJob2ID, r1Job1.ParentJobID)
payload := getWorkflowCallPayloadFromTask(t, r1Job1Task)
if assert.Len(t, payload.Inputs, 5) {
assert.Equal(t, "from_caller_job2", payload.Inputs["str_input"])
assert.EqualValues(t, 230, payload.Inputs["num_input"])
assert.Equal(t, true, payload.Inputs["bool_input"])
assert.Equal(t, "abcdef", payload.Inputs["parent_var"])
assert.Equal(t, "prepared_data", payload.Inputs["needs_out"])
}
if assert.Len(t, r1Job1Task.Secrets, 3) {
assert.Contains(t, r1Job1Task.Secrets, "GITEA_TOKEN")
assert.Contains(t, r1Job1Task.Secrets, "GITHUB_TOKEN")
assert.Equal(t, "secRET-t0Ken", r1Job1Task.Secrets["PARENT_TOKEN"])
}
customRunner.fetchNoTask(t)
defaultRunner.execTask(t, r1Job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// reusable1_job3 (a nested caller) needs reusable1_job2, so it stays Blocked until r1j2 succeeds.
r1Job3Pre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
assert.Equal(t, actions_model.StatusBlocked, r1Job3Pre.Status)
assert.False(t, r1Job3Pre.IsExpanded)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"}))
r1Job2Task := customRunner.fetchTask(t) // for reusable1_job2
_, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id)
assert.Equal(t, "reusable1_job2", r1Job2.JobID)
r1Job2ID = r1Job2.ID
r1Job2AttemptJobID = r1Job2.AttemptJobID
if assert.Len(t, r1Job2Task.Needs, 1) {
assert.Contains(t, r1Job2Task.Needs, "reusable1_job1")
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, r1Job2Task.Needs["reusable1_job1"].Result)
}
customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"r1j2_out": "r1j2_out_data",
},
})
// Now reusable1_job3 expands and reusable2_job1 becomes runnable.
r1Job3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
assert.True(t, r1Job3.IsReusableCaller)
assert.True(t, r1Job3.IsExpanded)
assert.Equal(t, callerJob2ID, r1Job3.ParentJobID)
r1Job3ID = r1Job3.ID
r1Job3AttemptJobID = r1Job3.AttemptJobID
r2Job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"})
assert.Equal(t, r1Job3ID, r2Job1.ParentJobID)
r2Job1AttemptJobID = r2Job1.AttemptJobID
r2Job1Task := defaultRunner.fetchTask(t) // for reusable2_job1
_, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id)
assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID)
assert.Equal(t, r1Job3ID, fetchedR2Job1.ParentJobID)
r2Job1Payload := getWorkflowCallPayloadFromTask(t, r2Job1Task)
if assert.Len(t, r2Job1Payload.Inputs, 1) {
assert.Equal(t, "from_caller_job2", r2Job1Payload.Inputs["msg"])
}
defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2ID})
assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status)
callerJob3Task := defaultRunner.fetchTask(t) // for caller_job3
_, callerJob3, _ := getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id)
assert.Equal(t, "caller_job3", callerJob3.JobID)
if assert.Len(t, callerJob3Task.Needs, 1) {
assert.Contains(t, callerJob3Task.Needs, "caller_job2")
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result)
if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) {
assert.Equal(t, "r1j2_out_data", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"])
}
}
defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, callerRun.Status)
})
t.Run("Rerun 'reusable1_job2'", func(t *testing.T) {
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, r1Job2ID))
user2Session.MakeRequest(t, req, http.StatusOK)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2})
assert.Equal(t, actions_model.StatusWaiting, attempt2.Status)
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob2AttemptJobID})
assert.Equal(t, actions_model.StatusWaiting, callerJob2.Status)
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob3AttemptJobID})
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
// reusable1_job3 needs reusable1_job2, so rerunning r1j2 pulls r1j3 (and its subtree) into the rerun set
r1Job3Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
assert.Equal(t, actions_model.StatusBlocked, r1Job3Attempt2.Status)
assert.True(t, r1Job3Attempt2.IsReusableCaller)
assert.False(t, r1Job3Attempt2.IsExpanded)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"}))
defaultRunner.fetchNoTask(t)
r1Job2Task := customRunner.fetchTask(t)
_, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id)
assert.Equal(t, "reusable1_job2", r1Job2.JobID)
assert.Equal(t, callerJob2.ID, r1Job2.ParentJobID)
assert.Equal(t, r1Job2AttemptJobID, r1Job2.AttemptJobID)
assert.Equal(t, actions_model.StatusRunning, r1Job2.Status)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusRunning, run.Status)
customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"r1j2_out": "r1j2_out_data_updated",
},
})
// r1j3 expands again. Its child reuses the AttemptJobID from attempt 1
r1Job3Attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
assert.True(t, r1Job3Attempt2.IsExpanded)
r2Job1Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"})
assert.Equal(t, r2Job1AttemptJobID, r2Job1Attempt2.AttemptJobID)
assert.Equal(t, r1Job3Attempt2.ID, r2Job1Attempt2.ParentJobID)
r2Job1Task := defaultRunner.fetchTask(t)
_, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id)
assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID)
defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
callerJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2.ID})
assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status)
callerJob3Task := defaultRunner.fetchTask(t)
_, callerJob3, _ = getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id)
assert.Equal(t, "caller_job3", callerJob3.JobID)
if assert.Len(t, callerJob3Task.Needs, 1) {
assert.Contains(t, callerJob3Task.Needs, "caller_job2")
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result)
if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) {
assert.Equal(t, "r1j2_out_data_updated", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"])
}
}
defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2})
assert.Equal(t, actions_model.StatusSuccess, attempt2.Status)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
})
t.Run("Cross-repo reusable workflow with collaborative owner", func(t *testing.T) {
// libRepo: private, owned by user2.
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-private", true)
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml",
`name: ReusableLib
on:
workflow_call:
inputs:
from:
type: string
jobs:
lib_job:
runs-on: ubuntu-latest
steps:
- run: echo hello-${{ inputs.from }}
`)
// consumerRepo: private, owned by user4.
consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-cross-repo", true)
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-cross-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml",
`name: CrossCaller
on: push
jobs:
cross_job:
uses: user2/reusable-lib-private/.gitea/workflows/reusable_lib.yaml@main
with:
from: 'consumer'
`)
// Phase 1: no grant. The cross-repo read check fails, and NO ActionRun row gets persisted.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
runner.fetchNoTask(t)
// Phase 2: user2 (libRepo owner) adds user4 (consumer owner) as a Collaborative Owner of libRepo.
addCollabReq := NewRequestWithValues(t, "POST",
fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name),
map[string]string{"collaborative_owner": user4.Name})
user2Session.MakeRequest(t, addCollabReq, http.StatusOK)
// Phase 3: trigger the workflow again
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, "marker.txt", "trigger after grant")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
assert.True(t, crossJob.IsReusableCaller)
assert.True(t, crossJob.IsExpanded)
assert.Equal(t, actions_model.StatusWaiting, crossJob.Status)
libJobTask := runner.fetchTask(t)
_, fetchedLibJob, _ := getTaskAndJobAndRunByTaskID(t, libJobTask.Id)
assert.Equal(t, "lib_job", fetchedLibJob.JobID)
assert.Equal(t, crossJob.ID, fetchedLibJob.ParentJobID)
assert.Equal(t, consumerRepo.ID, fetchedLibJob.RepoID)
payload := getWorkflowCallPayloadFromTask(t, libJobTask)
if assert.Len(t, payload.Inputs, 1) {
assert.Equal(t, "consumer", payload.Inputs["from"])
}
crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID})
assert.Equal(t, actions_model.StatusRunning, crossJob.Status)
runner.execTask(t, libJobTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID})
assert.Equal(t, actions_model.StatusSuccess, crossJob.Status)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
t.Run("Public caller denied private target even with collaborative owner", func(t *testing.T) {
// Isolates the run.Repo.IsPrivate gate: a public caller must be denied a private target even with a
// collaborative-owner grant, since allowing it would expose private workflow content in a public run.
// libRepo: private, owned by user2.
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-public-denied", true)
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml",
`name: ReusableLib
on:
workflow_call:
jobs:
lib_job:
runs-on: ubuntu-latest
steps:
- run: echo hello
`)
// Grant first: user2 adds user4 as a collaborative owner of the private libRepo, so the grant is
// satisfied and the public-caller gate is the only thing that can deny access.
addCollabReq := NewRequestWithValues(t, "POST",
fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name),
map[string]string{"collaborative_owner": user4.Name})
user2Session.MakeRequest(t, addCollabReq, http.StatusOK)
// consumerRepo: public, owned by user4.
consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-public-denied", false)
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-public-denied-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml",
`name: CrossCaller
on: push
jobs:
cross_job:
uses: user2/reusable-lib-public-denied/.gitea/workflows/reusable_lib.yaml@main
`)
// Denied: the cross-repo read check fails for the public caller, so NO ActionRun is persisted and no task is dispatched.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
runner.fetchNoTask(t)
})
t.Run("Cross-repo callee with same-repo nested uses", func(t *testing.T) {
// A same-repo `uses: ./...` inside a cross-repo reusable callee must resolve relative to the callee's own repo (matching GitHub's behavior), not the original triggering repo.
// Place a util.yaml with a distinguishable job name in BOTH repos to detect mis-resolution.
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-nested", false)
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/util.yaml",
`name: UtilLib
on:
workflow_call:
jobs:
util_lib_job:
runs-on: ubuntu-latest
steps:
- run: echo from-lib
`)
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/lib.yaml",
`name: LibNested
on:
workflow_call:
jobs:
call_util_in_lib:
uses: ./.gitea/workflows/util.yaml
`)
consumerAPIRepo := createActionsTestRepo(t, user4Token, "consumer-nested-uses", false)
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
// A *different* util.yaml in the consumer repo: if `./` mis-resolves we'd see this job's name.
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/util.yaml",
`name: UtilConsumer
on:
workflow_call:
jobs:
util_consumer_job:
runs-on: ubuntu-latest
steps:
- run: echo from-consumer
`)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-nested-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/caller.yaml",
`name: NestedCaller
on: push
jobs:
cross_job:
uses: user2/reusable-lib-nested/.gitea/workflows/lib.yaml@main
`)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
assert.True(t, crossJob.IsReusableCaller)
assert.True(t, crossJob.IsExpanded)
// cross_job's children come from libRepo/lib.yaml - their source must be libRepo + libRepo's commit.
libHead, err := gitrepo.GetBranchCommitID(t.Context(), libRepo, libRepo.DefaultBranch)
require.NoError(t, err)
callUtilJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "call_util_in_lib", ParentJobID: crossJob.ID})
assert.True(t, callUtilJob.IsReusableCaller)
assert.Equal(t, libRepo.ID, callUtilJob.WorkflowSourceRepoID)
assert.Equal(t, libHead, callUtilJob.WorkflowSourceCommitSHA)
// call_util_in_lib has `uses: ./.gitea/workflows/util.yaml`, so its children should come from libRepo/util.yaml
assert.True(t, callUtilJob.IsExpanded)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_lib_job", ParentJobID: callUtilJob.ID})
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_consumer_job"})
})
t.Run("Missing callee file", func(t *testing.T) {
// A caller workflow references a callee path that does not exist in the repo.
apiRepo := createActionsTestRepo(t, user2Token, "caller-missing-callee", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on: push
jobs:
plain_job:
runs-on: ubuntu-latest
steps:
- run: echo 'job'
call_missing:
uses: ./.gitea/workflows/does-not-exist.yml
`)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
t.Run("Nested caller with missing callee fails instead of blocking", func(t *testing.T) {
// When the expansion hits a terminal error (e.g. missing callee), the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-missing-callee", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on: push
jobs:
plain_job:
runs-on: ubuntu-latest
steps:
- run: echo 'job'
bad_caller:
needs: plain_job
uses: ./.gitea/workflows/does-not-exist.yml
`)
// plain_job runs first; bad_caller is Blocked on needs and is NOT expanded at creation.
plainTask := runner.fetchTask(t)
_, plainJob, run := getTaskAndJobAndRunByTaskID(t, plainTask.Id)
assert.Equal(t, "plain_job", plainJob.JobID)
badCallerPre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "bad_caller"})
assert.Equal(t, actions_model.StatusBlocked, badCallerPre.Status)
assert.False(t, badCallerPre.IsExpanded)
runner.execTask(t, plainTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// The emitter now tries to expand bad_caller, hits the missing callee, and fails the caller.
badCaller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: badCallerPre.ID})
assert.Equal(t, actions_model.StatusFailure, badCaller.Status)
// No children were inserted (the terminal error precedes the child inserts).
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: badCallerPre.ID}))
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, finalRun.Status)
runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck
})
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {
// user2 owns the base repo, configures a secret, and registers a reusable workflow that declares a required secret.
// The caller workflow uses `secrets: inherit`.
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-pr-inherit-test", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
// Real secret that must never reach a fork PR task.
req := NewRequestWithJSON(t, "PUT",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/leaked_secret", baseRepo.OwnerName, baseRepo.Name),
api.CreateOrUpdateSecretOption{Data: "MUST-NOT-LEAK"}).AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusCreated)
runner := newMockRunner()
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-fork-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/reusable.yaml",
`name: Reusable
on:
workflow_call:
secrets:
leaked_secret:
jobs:
callee:
runs-on: ubuntu-latest
steps:
- run: echo
`)
createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/caller.yaml",
`name: Caller
on: pull_request
jobs:
call_reusable:
uses: ./.gitea/workflows/reusable.yaml
secrets: inherit
`)
// user4 forks
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
&api.CreateForkOption{Name: new("fork-pr-inherit-test-fork")}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
// user4 pushes a change on the fork and opens a PR to base
doAPICreateFile(user4APICtx, "user4-fix.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "user4/branch",
Message: "create user4-fix.txt",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("fix")),
})(t)
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":user4/branch")(t)
// Approve the fork PR run.
forkRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID})
assert.True(t, forkRun.IsForkPullRequest)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, forkRun.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
task := runner.fetchTask(t)
_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "callee", taskJob.JobID)
assert.Equal(t, forkRun.ID, taskRun.ID)
// Only the auto-issued tokens should be present. The user-defined `leaked_secret` must not appear.
assert.Contains(t, task.Secrets, "GITEA_TOKEN")
assert.Contains(t, task.Secrets, "GITHUB_TOKEN")
assert.NotContains(t, task.Secrets, "leaked_secret")
for name, value := range task.Secrets {
assert.NotEqual(t, "MUST-NOT-LEAK", value, "secret %q leaked the base repo's secret value into a fork PR task", name)
}
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
})
t.Run("pull_request_target resolves a local reusable workflow at the base commit", func(t *testing.T) {
apiBaseRepo := createActionsTestRepo(t, user2Token, "prt-reusable-test", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-prt-runner", []string{"ubuntu-latest"}, false)
reusablePath := ".gitea/workflows/reusable.yaml"
// A pull_request_target run's workflow should always come from the base branch.
createRepoWorkflowFile(t, user2, user2Token, baseRepo, reusablePath, `name: Reusable
on:
workflow_call:
jobs:
trusted:
runs-on: ubuntu-latest
steps:
- run: echo trusted
`)
baseFile := createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, ".gitea/workflows/prt.yaml",
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create prt.yaml", `name: PRT
on: pull_request_target
jobs:
call_reusable:
uses: ./.gitea/workflows/reusable.yaml
secrets: inherit
`))
baseSHA := baseFile.Commit.SHA
// user4 forks
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
&api.CreateForkOption{Name: new("prt-reusable-test-fork")}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
// user4 rewrites the reusable workflow the base branch calls into, and opens a PR
req = NewRequest(t, "GET",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath)).AddTokenAuth(user4Token)
resp = MakeRequest(t, req, http.StatusOK)
forkReusable := DecodeJSON(t, resp, &api.ContentsResponse{})
req = NewRequestWithJSON(t, "PUT",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath), &api.UpdateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "fork-branch",
Message: "rewrite the reusable workflow",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
SHA: forkReusable.SHA,
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: Reusable
on:
workflow_call:
jobs:
from-fork:
runs-on: ubuntu-latest
steps:
- run: echo from-fork
`)),
}).AddTokenAuth(user4Token)
resp = MakeRequest(t, req, http.StatusOK)
forkHeadSHA := DecodeJSON(t, resp, &api.FileResponse{}).Commit.SHA
require.NotEqual(t, baseSHA, forkHeadSHA)
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":fork-branch")(t)
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
prtRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
assert.Equal(t, actions_module.GithubEventPullRequestTarget, prtRun.TriggerEvent)
assert.True(t, prtRun.IsForkPullRequest)
assert.False(t, prtRun.NeedApproval)
// The run still points at the PR head, but its workflow source must be the base commit.
assert.Equal(t, forkHeadSHA, prtRun.CommitSHA)
assert.Equal(t, baseSHA, prtRun.WorkflowCommitSHA)
caller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "call_reusable"})
assert.Equal(t, baseSHA, caller.WorkflowSourceCommitSHA)
assert.NotContains(t, string(caller.ReusableWorkflowContent), "from-fork")
// The caller has no needs, so it is expanded inline at insert time: the child comes from the base branch.
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "from-fork"})
child := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "trusted"})
assert.Equal(t, caller.ID, child.ParentJobID)
assert.Equal(t, baseSHA, child.WorkflowSourceCommitSHA)
task := runner.fetchTask(t)
_, taskJob, _ := getTaskAndJobAndRunByTaskID(t, task.Id)
require.Equal(t, "trusted", taskJob.JobID)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
})
t.Run("Caller alternates expanding across attempts", func(t *testing.T) {
apiRepo := createActionsTestRepo(t, user2Token, "caller-walkback-test", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-walkback-runner", []string{"ubuntu-latest"}, false)
// Scenario:
// attempt 1: gate succeeds -> caller expands -> inner runs (records inner.AttemptJobID = N)
// attempt 2: rerun gate, mock Failure -> caller is Skipped without expanding (no children inserted)
// attempt 3: rerun gate, mock Success -> caller expands again -> inner.AttemptJobID must equal N
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml",
`name: Lib
on:
workflow_call:
jobs:
inner:
runs-on: ubuntu-latest
steps:
- run: echo inner
`)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/main.yaml",
`name: Main
on:
push:
paths:
- '.gitea/workflows/main.yaml'
jobs:
gate:
runs-on: ubuntu-latest
steps:
- run: echo gate
caller:
needs: [gate]
uses: ./.gitea/workflows/lib.yaml
`)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
runID := run.ID
latestAttempt := func() *actions_model.ActionRunAttempt {
r := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: r.LatestAttemptID})
}
jobInLatest := func(jobID string) *actions_model.ActionRunJob {
a := latestAttempt()
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: a.ID, JobID: jobID})
}
// attempt 1: gate Success -> caller expands -> inner runs
gate1Task := runner.fetchTask(t)
_, gate1, _ := getTaskAndJobAndRunByTaskID(t, gate1Task.Id)
assert.Equal(t, "gate", gate1.JobID)
runner.execTask(t, gate1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
inner1Task := runner.fetchTask(t)
_, inner1, _ := getTaskAndJobAndRunByTaskID(t, inner1Task.Id)
assert.Equal(t, "inner", inner1.JobID)
innerAttemptJobID := inner1.AttemptJobID
callerAttempt1 := jobInLatest("caller")
assert.True(t, callerAttempt1.IsExpanded)
assert.Equal(t, callerAttempt1.ID, inner1.ParentJobID)
runner.execTask(t, inner1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
// attempt 2: rerun gate, mock Failure -> caller stays unexpanded (Skipped)
gateLatest := jobInLatest("gate")
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
gate2Task := runner.fetchTask(t)
_, gate2, _ := getTaskAndJobAndRunByTaskID(t, gate2Task.Id)
assert.Equal(t, "gate", gate2.JobID)
runner.execTask(t, gate2Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE})
runner.fetchNoTask(t) // no inner because caller did not expand
attempt2 := latestAttempt()
assert.Equal(t, actions_model.StatusFailure, attempt2.Status)
callerAttempt2 := jobInLatest("caller")
assert.Equal(t, actions_model.StatusSkipped, callerAttempt2.Status)
assert.False(t, callerAttempt2.IsExpanded)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "inner"}))
// attempt 3: rerun gate, mock Success -> caller expands and inner reuses attempt 1's AttemptJobID
gateLatest = jobInLatest("gate")
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
gate3Task := runner.fetchTask(t)
runner.execTask(t, gate3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
inner3Task := runner.fetchTask(t)
_, inner3, _ := getTaskAndJobAndRunByTaskID(t, inner3Task.Id)
assert.Equal(t, "inner", inner3.JobID)
assert.Equal(t, innerAttemptJobID, inner3.AttemptJobID)
runner.execTask(t, inner3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
t.Run("No-needs caller if evaluated inline: false skips, true expands", func(t *testing.T) {
// A no-needs reusable-workflow caller is processed inline during InsertRun, where its own
// `if:` is now evaluated before expansion:
// - a false `if:` skips the caller without inserting any children, and the skip is
// propagated to a dependent job (via the post-commit emitter kick);
// - a true `if:` still expands the caller into its child jobs.
apiRepo := createActionsTestRepo(t, user2Token, "caller-inline-if-test", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml",
`name: Lib
on:
workflow_call:
jobs:
inner:
runs-on: ubuntu-latest
steps:
- run: echo inner
`)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on:
push:
paths:
- '.gitea/workflows/caller.yaml'
jobs:
will_skip:
if: ${{ false }}
uses: ./.gitea/workflows/lib.yaml
will_run:
if: ${{ true }}
uses: ./.gitea/workflows/lib.yaml
after_skip:
needs: [will_skip]
runs-on: ubuntu-latest
steps:
- run: echo after_skip
`)
// drain the emitter queue so the skip has propagated to the dependent job
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
runID := run.ID
// will_skip: a caller with a false `if:` is skipped inline and never expands (no children inserted).
willSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "will_skip"})
assert.True(t, willSkip.IsReusableCaller)
assert.False(t, willSkip.IsExpanded)
assert.Equal(t, actions_model.StatusSkipped, willSkip.Status)
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: runID, ParentJobID: willSkip.ID})
// will_run: a caller with a true `if:` still expands into its child job.
willRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "will_run"})
assert.True(t, willRun.IsReusableCaller)
assert.True(t, willRun.IsExpanded)
innerChild := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "inner"})
assert.Equal(t, willRun.ID, innerChild.ParentJobID)
// after_skip: a dependent of the skipped caller resolves to Skipped instead of staying Blocked.
afterSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "after_skip"})
assert.Equal(t, actions_model.StatusSkipped, afterSkip.Status)
})
})
}
// token must belong to u (the commit identity) and have write access to repo. Reuse the caller's
// existing token rather than logging in per call, which would re-run bcrypt password verification each time.
func createRepoWorkflowFile(t *testing.T, u *user_model.User, token string, repo *repo_model.Repository, treePath, content string) {
opts := getWorkflowCreateFileOptions(u, repo.DefaultBranch, "create "+treePath, content)
createWorkflowFile(t, token, repo.OwnerName, repo.Name, treePath, opts)
}
func getWorkflowCallPayloadFromTask(t *testing.T, runnerTask *runnerv1.Task) *api.WorkflowCallPayload {
eventJSON, err := runnerTask.GetContext().Fields["event"].GetStructValue().MarshalJSON()
assert.NoError(t, err)
var payload api.WorkflowCallPayload
assert.NoError(t, json.Unmarshal(eventJSON, &payload))
return &payload
}
@@ -9,15 +9,15 @@ import (
"net/url"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
actions_web "gitea.dev/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -63,19 +63,51 @@ jobs:
task2 := runner2.fetchTask(t)
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 0, "text/markdown", []byte("### Hello summary\n\nFrom first step.\n")))
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 1, "text/markdown", []byte("From second step.\n")))
// A second job's summary in the same run/attempt: the run view must include it,
// but the single-job view must scope it out.
otherJobID := job1.ID + 1
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, otherJobID, 0, "text/markdown", []byte("### Other job summary\n")))
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, 999999))
user2Session.MakeRequest(t, req, http.StatusNotFound)
// run1 and job1 belong to repo1, success
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
findSummary := func(viewResp *actions_web.ViewResponse, jobID int64) *actions_web.ViewJobSummary {
for _, s := range viewResp.State.Run.JobSummaries {
if s.JobID == jobID {
return s
}
}
return nil
}
assertJob1Summary := func(t *testing.T, s *actions_web.ViewJobSummary) {
t.Helper()
require.NotNil(t, s)
assert.Contains(t, string(s.SummaryHTML), "Hello summary")
assert.Contains(t, string(s.SummaryHTML), "From second step")
}
// Run view: summaries for every job in the run.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
resp := user2Session.MakeRequest(t, req, http.StatusOK)
var viewResp actions_web.ViewResponse
DecodeJSON(t, resp, &viewResp)
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
require.Len(t, viewResp.State.Run.JobSummaries, 2)
assertJob1Summary(t, findSummary(viewResp, job1.ID))
assert.Contains(t, string(findSummary(viewResp, otherJobID).SummaryHTML), "Other job summary")
// Job view: scoped server-side to the requested job, the other job's summary excluded.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
resp = user2Session.MakeRequest(t, req, http.StatusOK)
viewResp = DecodeJSON(t, resp, &actions_web.ViewResponse{})
assert.Len(t, viewResp.State.Run.Jobs, 1)
assert.Equal(t, job1.ID, viewResp.State.Run.Jobs[0].ID)
require.Len(t, viewResp.State.Run.JobSummaries, 1)
assertJob1Summary(t, findSummary(viewResp, job1.ID))
assert.Nil(t, findSummary(viewResp, otherJobID))
// run2 and job2 do not belong to repo1, failure
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job2.ID))
@@ -212,7 +244,7 @@ func testActionsRouteForLegacyIndexBasedURL(t *testing.T) {
// Best-effort compatibility prefers the run ID when the same number also exists as a legacy run index.
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, collisionRun.Index))
resp = user2Session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-run-id="%d"`, normalRun.ID)) // because collisionRun.Index == normalRun.ID
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-actions-view-url="/%s/%s/actions/runs/%d"`, user2.Name, repo.Name, normalRun.ID))
// by_index=1 should force the summary page to use the legacy run index interpretation.
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d?by_index=1", user2.Name, repo.Name, collisionRun.Index))
@@ -6,14 +6,16 @@ package integration
import (
"fmt"
"net/http"
"strings"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -163,4 +165,62 @@ func TestActionsRunnerModify(t *testing.T) {
assertSuccess(t, sessionAdmin, adminWebURL, globalRunner.ID)
})
})
t.Run("BulkAction", func(t *testing.T) {
// Previous subtests deleted all runners; create a fresh set scoped to this subtest.
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-1", TokenHash: "e", UUID: "e"}))
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-2", TokenHash: "f", UUID: "f"}))
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-3", TokenHash: "g", UUID: "g"}))
r1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-1"})
r2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-2"})
r3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-3"})
allIDs := []int64{r1.ID, r2.ID, r3.ID}
bulkURL := adminWebURL + "/bulk"
doBulk := func(t *testing.T, sess *TestSession, action string, ids []int64, expectedStatus int) {
req := NewRequestWithValues(t, "POST", bulkURL, map[string]string{
"action": action,
"ids": strings.Join(base.Int64sToStrings(ids), ","),
})
sess.MakeRequest(t, req, expectedStatus)
}
t.Run("NonAdminForbidden", func(t *testing.T) {
doBulk(t, sessionUser2, "disable", allIDs, http.StatusForbidden)
for _, id := range allIDs {
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.False(t, v.IsDisabled, "runner %d should not have been disabled", id)
}
})
t.Run("InvalidAction", func(t *testing.T) {
doBulk(t, sessionAdmin, "evict", allIDs, http.StatusBadRequest)
})
t.Run("EmptyIDs", func(t *testing.T) {
doBulk(t, sessionAdmin, "delete", nil, http.StatusBadRequest)
for _, id := range allIDs {
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
}
})
t.Run("DisableEnable", func(t *testing.T) {
doBulk(t, sessionAdmin, "disable", allIDs, http.StatusOK)
for _, id := range allIDs {
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.True(t, v.IsDisabled, "runner %d should be disabled", id)
}
doBulk(t, sessionAdmin, "enable", allIDs, http.StatusOK)
for _, id := range allIDs {
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.False(t, v.IsDisabled, "runner %d should be enabled", id)
}
})
t.Run("Delete", func(t *testing.T) {
doBulk(t, sessionAdmin, "delete", allIDs, http.StatusOK)
for _, id := range allIDs {
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: id})
}
})
})
}
@@ -0,0 +1,130 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/timeutil"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestFindRunnersSortByStatus verifies that sorting by status ranks runners by
// their computed status (active, then idle, then offline) instead of interleaving
// idle runners with active ones by raw last_online, and that a disabled runner
// sinks to the bottom of its status group rather than mixing with enabled runners.
//
// It lives in tests/integration rather than a unit test because the status rank is
// a database-evaluated CASE expression; unit tests only run against SQLite, so the
// expression must be exercised against MySQL/PostgreSQL/MSSQL in CI to catch dialect
// differences.
func TestFindRunnersSortByStatus(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ctx := t.Context()
const ownerID = 1001
now := time.Now()
// An idle runner that went online most recently would sort before an active
// runner when ordering by last_online alone; the status rank must override that.
insert := func(name string, lastOnline, lastActive time.Time, disabled bool) {
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunner{
Name: name,
UUID: "STATUS-SORT-" + name,
TokenHash: "status-sort-token-" + name,
OwnerID: ownerID,
LastOnline: timeutil.TimeStamp(lastOnline.Unix()),
LastActive: timeutil.TimeStamp(lastActive.Unix()),
IsDisabled: disabled,
}))
}
// Each disabled runner has the most recent last_online within its status group,
// so it would sort first in that group if the disabled flag were ignored; the
// last_active value keeps it in the intended group (offline<idle<active).
insert("active", now.Add(-8*time.Second), now.Add(-5*time.Second), false)
insert("active-disabled", now, now.Add(-2*time.Second), true)
insert("idle", now.Add(-30*time.Second), now.Add(-20*time.Second), false)
insert("idle-disabled", now, now.Add(-20*time.Second), true)
insert("offline", now.Add(-3*time.Minute), now.Add(-3*time.Minute), false)
insert("offline-disabled", now.Add(-90*time.Second), now.Add(-90*time.Second), true)
names := func(runners []*actions_model.ActionRunner) []string {
out := make([]string, len(runners))
for i, r := range runners {
out[i] = r.Name
}
return out
}
// Active group first, then idle, then offline; within each group enabled before disabled.
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "online"})
require.NoError(t, err)
assert.Equal(t, []string{
"active", "active-disabled",
"idle", "idle-disabled",
"offline", "offline-disabled",
}, names(runners))
// The descending status sort reverses the group order but still keeps disabled
// runners at the bottom of their group.
runners, err = db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "offline"})
require.NoError(t, err)
assert.Equal(t, []string{
"offline", "offline-disabled",
"idle", "idle-disabled",
"active", "active-disabled",
}, names(runners))
}
// TestFindRunnersPaginationNoDuplicates verifies that the unique id tiebreaker in
// FindRunnerOptions.ToOrders keeps pagination deterministic when the primary sort
// key (last_online) is tied for every runner. It is an integration test so the
// ordering is validated against every supported database, not only SQLite.
func TestFindRunnersPaginationNoDuplicates(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ctx := t.Context()
// Create several runners that all share the same last_online value so the
// primary sort key (last_online) is tied for all of them.
const ownerID = 1000
const count = 6
for i := range count {
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunner{
Name: "paginated-runner",
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
OwnerID: ownerID,
RepoID: 0,
LastOnline: 42,
}))
}
// Page through the runners and ensure every id is returned exactly once.
seen := make(map[int64]int)
const pageSize = 2
for page := 1; ; page++ {
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
OwnerID: ownerID,
})
require.NoError(t, err)
if len(runners) == 0 {
break
}
for _, r := range runners {
seen[r.ID]++
}
}
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
for id, n := range seen {
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
}
}
@@ -10,13 +10,13 @@ import (
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/setting"
pingv1 "gitea.dev/actions-proto-go/ping/v1"
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
auth_model "gitea.dev/models/auth"
"gitea.dev/modules/setting"
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -86,10 +86,9 @@ func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, run
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", ownerName, repoName)).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var registrationToken struct {
registrationToken := DecodeJSON(t, resp, &struct {
Token string `json:"token"`
}
DecodeJSON(t, resp, &registrationToken)
}{})
r.doRegister(t, runnerName, registrationToken.Token, labels, ephemeral)
}
@@ -9,21 +9,25 @@ import (
"strings"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/migration"
api "code.gitea.io/gitea/modules/structs"
mirror_service "code.gitea.io/gitea/services/mirror"
repo_service "code.gitea.io/gitea/services/repository"
files_service "code.gitea.io/gitea/services/repository/files"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/migration"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
migrations_service "gitea.dev/services/migrations"
mirror_service "gitea.dev/services/mirror"
repo_service "gitea.dev/services/repository"
files_service "gitea.dev/services/repository/files"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestScheduleUpdate(t *testing.T) {
@@ -144,6 +148,11 @@ jobs:
func testScheduleUpdateMirrorSync(t *testing.T) {
doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) {
// the mirror sync re-validates the remote URL, which rejects the local test server unless local
// networks are allowed; migrations.Init rebuilds the host allow-list from the setting
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
require.NoError(t, migrations_service.Init())
// create mirror repo
opts := migration.MigrateOptions{
RepoName: "actions-schedule-mirror",
@@ -0,0 +1,548 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strconv"
"testing"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/queue"
api "gitea.dev/modules/structs"
"gitea.dev/services/forms"
repo_service "gitea.dev/services/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const scopedPushWorkflow = `name: Scoped Push
on: push
jobs:
scoped-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped
`
const scopedPRWorkflow = `name: Scoped PR
on: pull_request
jobs:
scoped-pr-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped-pr
`
func TestActionsScopedWorkflows(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
// createTestRepo creates an Actions-enabled repo owned by user2, used as a scoped-workflow source or consumer.
createTestRepo := func(t *testing.T, name string, private bool) *repo_model.Repository {
apiRepo := createActionsTestRepo(t, user2Token, name, private)
return unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
}
// registerUserScopedSource registers `source` as a user-level scoped-workflow source for user2 and marks `required` entry names
registerUserScopedSource := func(t *testing.T, source *repo_model.Repository, required ...string) {
addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add",
map[string]string{"repo_name": source.Name})
user2Session.MakeRequest(t, addReq, http.StatusOK)
t.Cleanup(func() {
removeReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/remove",
map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
user2Session.MakeRequest(t, removeReq, http.StatusOK)
})
if len(required) > 0 {
vals := url.Values{"repo_id": {strconv.FormatInt(source.ID, 10)}, "workflow_ids": required, "required_workflow_ids": required}
for _, id := range required {
// a pattern that matches the source's scoped check regardless of its `name:` (each test source has one workflow)
vals.Set("required_patterns["+id+"]", source.FullName()+": * / *")
}
reqReq := NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals)
user2Session.MakeRequest(t, reqReq, http.StatusOK)
}
}
t.Run("Trigger and run creation", func(t *testing.T) {
// Registered at INSTANCE level via the admin route (owner/name resolution + OwnerID=0 storage);
// the trigger->execute->rerun below proves an instance-level source drives a consumer run end-to-end and that a rerun stays scoped.
adminSession := loginUser(t, "user1")
source := createTestRepo(t, "sw-trigger-source", false)
// commit the scoped workflow BEFORE registering so the source's own push does not self-trigger.
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
adminAdd := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/add", map[string]string{"repo_name": source.FullName()})
adminSession.MakeRequest(t, adminAdd, http.StatusOK)
t.Cleanup(func() {
rm := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/remove", map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
adminSession.MakeRequest(t, rm, http.StatusOK)
})
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: 0, SourceRepoID: source.ID})
consumer := createTestRepo(t, "sw-trigger-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-trigger-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
assert.Equal(t, "push.yaml", run.WorkflowID)
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID}), "only the scoped run, no repo-level run")
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
// runs in the CONSUMER's context and reaches a terminal state
task := runner.fetchTask(t)
_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, consumer.ID, taskJob.RepoID)
assert.Equal(t, run.ID, taskRun.ID)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
// rerun: the rerun is still a scoped run and again executes in the consumer's context
rerunReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", consumer.OwnerName, consumer.Name, run.ID, job.ID))
user2Session.MakeRequest(t, rerunReq, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: run.ID, Attempt: 2})
task2 := runner.fetchTask(t)
_, taskJob2, taskRun2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, consumer.ID, taskJob2.RepoID)
assert.True(t, taskRun2.IsScopedRun, "the rerun is still a scoped run")
runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
t.Run("Opt-out", func(t *testing.T) {
// opt-out: a consumer can disable a non-required scoped workflow, but a required one cannot be disabled.
source := createTestRepo(t, "sw-optout-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
registerUserScopedSource(t, source) // non-required
// non-required: the kebab "Disable Workflow" item is an active link; disabling then makes a push produce no scoped run.
consumer := createTestRepo(t, "sw-optout-consumer", false)
optBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID)),
http.StatusOK).Body.String()
assert.Contains(t, optBody, "Disable Workflow")
assert.Contains(t, optBody, "disable?workflow=push.yaml", "non-required scoped workflow: Disable Workflow is a clickable link")
disableReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID))
user2Session.MakeRequest(t, disableReq, http.StatusOK)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "opted-out scoped workflow must not run")
// required: the kebab "Disable Workflow" item is rendered disabled (no link), and the disable endpoint rejects it.
reqSource := createTestRepo(t, "sw-optout-req-source", false)
createRepoWorkflowFile(t, user2, user2Token, reqSource, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
registerUserScopedSource(t, reqSource, "push.yaml") // required
reqConsumer := createTestRepo(t, "sw-optout-req-consumer", false)
requiredBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID)),
http.StatusOK).Body.String()
assert.Contains(t, requiredBody, "Disable Workflow")
assert.Contains(t, requiredBody, `class="item disabled"`, "required scoped workflow: Disable Workflow is rendered disabled")
assert.NotContains(t, requiredBody, "disable?workflow=push.yaml", "required scoped workflow: Disable Workflow has no clickable link")
rejectReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID))
user2Session.MakeRequest(t, rejectReq, http.StatusBadRequest) // scoped_required_cannot_disable
})
t.Run("Local uses resolves to source", func(t *testing.T) {
// uses: ./ in a scoped workflow resolves against the SOURCE repo, not the consumer.
// Here the reusable lib lives in the SCOPED workflow dir (allowed by ResolveUses), exercising that path end-to-end.
source := createTestRepo(t, "sw-uses-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/lib.yaml", `name: Lib
on:
workflow_call:
jobs:
lib_job_source:
runs-on: ubuntu-latest
steps:
- run: echo from-source
`)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/caller.yaml", `name: Caller
on: push
jobs:
caller_job:
uses: ./.gitea/scoped_workflows/lib.yaml
`)
consumer := createTestRepo(t, "sw-uses-consumer", false)
// a DIFFERENT lib at the same path in the consumer; if uses:./ mis-resolved we would see this job
createRepoWorkflowFile(t, user2, user2Token, consumer, ".gitea/scoped_workflows/lib.yaml", `name: Lib
on:
workflow_call:
jobs:
lib_job_consumer:
runs-on: ubuntu-latest
steps:
- run: echo from-consumer
`)
// register only AFTER both repos' scoped files exist, so the setup pushes do not trigger
registerUserScopedSource(t, source)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "caller.yaml"})
callerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "caller_job"})
assert.True(t, callerJob.IsReusableCaller)
assert.True(t, callerJob.IsExpanded)
assert.Equal(t, source.ID, callerJob.WorkflowSourceRepoID, "top-level caller's content source is the source repo")
// the expanded child comes from the SOURCE's lib.yaml, not the consumer's same-path file
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_source", ParentJobID: callerJob.ID})
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_consumer"})
})
t.Run("Workflow dispatch", func(t *testing.T) {
// a scoped on:workflow_dispatch workflow can be triggered manually from the consumer, via both the web form and the API
source := createTestRepo(t, "sw-dispatch-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/dispatch.yaml", `name: Scoped Dispatch
on: workflow_dispatch
jobs:
dispatch-job:
runs-on: ubuntu-latest
steps:
- run: echo dispatch
`)
registerUserScopedSource(t, source)
consumer := createTestRepo(t, "sw-dispatch-consumer", false)
// web form: /run?...&scoped_workflow_source_repo_id=
webReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=dispatch.yaml&scoped_workflow_source_repo_id=%d&ref=refs/heads/%s",
consumer.OwnerName, consumer.Name, source.ID, consumer.DefaultBranch))
user2Session.MakeRequest(t, webReq, http.StatusSeeOther)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml"})
assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
assert.NotEmpty(t, run.WorkflowCommitSHA, "scoped dispatch records the source default-branch commit")
assert.Contains(t, run.Ref, consumer.DefaultBranch, "dispatch runs on the chosen consumer ref")
// API: /actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=
apiReq := NewRequestWithURLValues(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID),
url.Values{"ref": {consumer.DefaultBranch}}).AddTokenAuth(user2Token)
MakeRequest(t, apiReq, http.StatusNoContent)
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml", Event: "workflow_dispatch"}),
"both the web form and the API created a scoped dispatch run")
})
t.Run("Required scoped check gates the PR merge", func(t *testing.T) {
// A required scoped workflow's check gates PR merges on a protected branch and cannot be bypassed,
// whether or not the branch enables its own status check. The scoped check is added to the required set dynamically.
source := createTestRepo(t, "sw-gate-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedPRWorkflow)
registerUserScopedSource(t, source, "pr.yaml") // required
// protectAndOpenPR protects consumer's default branch and opens a PR on `branch`, returning a merge-request builder.
// When statusCheckEnabled it also configures "ci/manual" as the only CONFIGURED required context and satisfies it,
// so the scoped check is the only thing that can gate the merge; otherwise the rule's own status check stays off.
protectAndOpenPR := func(t *testing.T, consumer *repo_model.Repository, branch string, statusCheckEnabled bool) func() *RequestWrapper {
pbValues := map[string]string{
"rule_name": consumer.DefaultBranch,
"enable_push": "true",
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
}
if statusCheckEnabled {
pbValues["enable_status_check"] = "true"
pbValues["status_check_contexts"] = "ci/manual"
}
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), pbValues), http.StatusSeeOther)
prFile := &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: consumer.DefaultBranch, NewBranchName: branch, Message: "pr change",
Author: api.Identity{Name: user2.Name, Email: user2.Email},
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
}
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "pr-change.txt", prFile)
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, branch)(t)
require.NoError(t, err)
if statusCheckEnabled {
// satisfy the configured "ci/manual" check so only the scoped check can gate the merge
manualStatus := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", consumer.OwnerName, consumer.Name, pr.Head.Sha),
api.CreateStatusOption{State: commitstatus.CommitStatusSuccess, Context: "ci/manual", TargetURL: "http://test.ci/"}).AddTokenAuth(user2Token)
user2Session.MakeRequest(t, manualStatus, http.StatusCreated)
}
return func() *RequestWrapper {
return NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
}
}
t.Run("pending blocks, success allows", func(t *testing.T) {
consumer := createTestRepo(t, "sw-gate-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-gate-runner", []string{"ubuntu-latest"}, false)
mergeReq := protectAndOpenPR(t, consumer, "gate-pr", true)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
assert.Equal(t, source.ID, run.WorkflowRepoID)
// the pending required scoped check blocks the merge
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
// the required scoped run succeeds -> merge allowed
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
})
t.Run("Actions disabled blocks merge (no bypass)", func(t *testing.T) {
// must-present: disabling the consumer's Actions unit so the required scoped workflow cannot run.
// Must BLOCK the merge (the required check is absent), not bypass it.
consumer := createTestRepo(t, "sw-noact-consumer", false)
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), consumer, nil, []unit_model.Type{unit_model.TypeActions}))
mergeReq := protectAndOpenPR(t, consumer, "noact-pr", true)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
"Actions disabled, so no scoped run is created")
// the required scoped check never posted a status -> must-present blocks the merge (no bypass)
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
})
t.Run("status check disabled: the scoped check still gates", func(t *testing.T) {
// the scoped check gates the merge even when the branch's OWN status check is off
consumer := createTestRepo(t, "sw-nocheck-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-nocheck-runner", []string{"ubuntu-latest"}, false)
mergeReq := protectAndOpenPR(t, consumer, "nocheck-pr", false)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
// pending scoped check blocks the merge despite the branch's own status check being off
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
// the required scoped run succeeds -> merge allowed
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
})
})
t.Run("Filtered required scoped check passes as skipped and allows merge", func(t *testing.T) {
// A required scoped workflow excluded by a paths filter posts a skipped (success) commit status,
// so the required check is satisfied and the PR can merge.
const scopedFilteredPRWorkflow = `name: Scoped Filtered PR
on:
pull_request:
paths:
- src/**
jobs:
scoped-filtered-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped-filtered
`
source := createTestRepo(t, "sw-filtered-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedFilteredPRWorkflow)
registerUserScopedSource(t, source, "pr.yaml") // required
consumer := createTestRepo(t, "sw-filtered-consumer", false)
// Protect the default branch (its own status check stays off, so only the required scoped check gates the merge).
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), map[string]string{
"rule_name": consumer.DefaultBranch,
"enable_push": "true",
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
}), http.StatusSeeOther)
// Open a PR that changes a file NOT matching the workflow's `paths: [src/**]`, so it is filtered out.
prFile := &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: consumer.DefaultBranch, NewBranchName: "filtered-pr", Message: "pr change",
Author: api.Identity{Name: user2.Name, Email: user2.Email},
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
}
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "docs.txt", prFile)
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, "filtered-pr")(t)
require.NoError(t, err)
// Filtered: no scoped run is created, but a skipped commit status is posted on the PR head.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "filtered scoped workflow creates no run")
assertSkippedCommitStatusExists(t, consumer.ID, pr.Head.Sha, "pull_request")
// The skipped (success) status satisfies the required scoped check (prefixed with the source repo), so the merge is allowed.
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
mergeReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
user2Session.MakeRequest(t, mergeReq, http.StatusOK)
})
t.Run("Settings page required patterns", func(t *testing.T) {
source := createTestRepo(t, "sw-settings-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/manual.yaml", `name: Manual
on: workflow_dispatch
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo
`) // workflow_dispatch posts no status -> the settings page must warn instead of listing contexts
registerUserScopedSource(t, source) // registered; each phase configures it via the /required endpoint
pattern := source.FullName() + ": * / *"
setConfigs := func(t *testing.T, vals url.Values) {
vals.Set("repo_id", strconv.FormatInt(source.ID, 10))
user2Session.MakeRequest(t, NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals), http.StatusOK)
}
loadSource := func(t *testing.T) *actions_model.ActionScopedWorkflowSource {
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
}
settingsBody := func(t *testing.T) string {
return user2Session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/actions/scoped-workflows"), http.StatusOK).Body.String()
}
t.Run("renders the saved pattern and display-name default", func(t *testing.T) {
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
body := settingsBody(t)
assert.Contains(t, body, `name="required_patterns[push.yaml]"`, "patterns textarea uses the field name the parser expects")
assert.Contains(t, body, pattern, "the saved pattern round-trips into the textarea")
// the default prefill must use the workflow display name so it matches the status context the run posts (name: Scoped Push)
assert.Contains(t, body, `data-default-pattern="`+source.FullName()+`: Scoped Push / *"`)
// the expected-checks preview derives the exact context a run posts (job scoped-job, event push) for live glob matching
assert.Contains(t, body, `data-context="`+source.FullName()+`: Scoped Push / scoped-job (push)"`)
})
t.Run("live pattern kept as history after un-require", func(t *testing.T) {
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
// un-require: the row still submits workflow_ids + its patterns (the hidden textarea), but not required_workflow_ids
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
cfg := loadSource(t).WorkflowConfigs["push.yaml"]
require.NotNil(t, cfg)
assert.False(t, cfg.Required, "no longer required")
assert.Equal(t, []string{pattern}, cfg.Patterns, "pattern retained as history")
assert.Contains(t, settingsBody(t), pattern, "history pattern still rendered, so re-requiring restores it")
})
t.Run("orphan config dropped when un-required", func(t *testing.T) {
// An orphan entry (gone.yaml: required for a file that no longer exists in the source) has no history worth keeping:
// un-checking Required must drop it entirely, unlike a live un-required workflow.
setConfigs(t, url.Values{
"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml", "gone.yaml"},
"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
})
require.True(t, loadSource(t).IsWorkflowRequired("gone.yaml"), "orphan kept while still required")
// un-require gone.yaml (its row + patterns are still submitted, as the settings page does); push.yaml stays required
setConfigs(t, url.Values{
"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml"},
"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
})
src := loadSource(t)
assert.Nil(t, src.WorkflowConfigs["gone.yaml"], "orphan dropped after un-require, not kept as history")
assert.True(t, src.IsWorkflowRequired("push.yaml"), "live required workflow kept")
})
t.Run("warns when a workflow posts no status checks", func(t *testing.T) {
// manual.yaml only runs on workflow_dispatch, which posts no commit status: instead of listing expected checks,
// its row shows a warning not to mark it required (must-present would block forever).
body := settingsBody(t)
assert.Contains(t, body, "posts no status checks", "the no-status-check warning is shown")
assert.NotContains(t, body, `data-context="`+source.FullName()+`: Manual /`, "a workflow_dispatch-only workflow must list no expected contexts")
})
})
t.Run("Distinct sources same filename", func(t *testing.T) {
// two DIFFERENT source repos with the same filename run independently
s1 := createTestRepo(t, "sw-multi-s1", false)
createRepoWorkflowFile(t, user2, user2Token, s1, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
s2 := createTestRepo(t, "sw-multi-s2", false)
createRepoWorkflowFile(t, user2, user2Token, s2, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
registerUserScopedSource(t, s1)
registerUserScopedSource(t, s2)
consumer := createTestRepo(t, "sw-multi-consumer", false)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "same-named ci.yaml from two sources run independently")
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s1.ID})
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s2.ID})
})
t.Run("Detection cache invalidates on source push", func(t *testing.T) {
// The detection parse is cached per (source, default-branch SHA).
source := createTestRepo(t, "sw-cache-source", false)
created := createWorkflowFile(t, user2Token, source.OwnerName, source.Name, ".gitea/scoped_workflows/ci.yaml",
getWorkflowCreateFileOptions(user2, source.DefaultBranch, "create ci", `name: CI
on: pull_request
jobs:
j:
runs-on: ubuntu-latest
steps:
- run: echo a
`))
registerUserScopedSource(t, source)
consumer := createTestRepo(t, "sw-cache-consumer", false)
// warm the cache at the source's current SHA: the source triggers on pull_request, so a consumer push is no match
createRepoWorkflowFile(t, user2, user2Token, consumer, "m1.txt", "trigger")
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
"source triggers on pull_request, so a consumer push must not create a scoped run")
// switch the source's trigger to push on its default branch
updateReq := NewRequestWithJSON(t, "PUT",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/.gitea/scoped_workflows/ci.yaml", source.OwnerName, source.Name),
&api.UpdateFileOptions{
SHA: created.Content.SHA,
FileOptions: api.FileOptions{BranchName: source.DefaultBranch, Message: "switch to push"},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: CI
on: push
jobs:
j:
runs-on: ubuntu-latest
steps:
- run: echo a
`)),
}).AddTokenAuth(user2Token)
MakeRequest(t, updateReq, http.StatusOK)
// the next consumer push must re-detect against the new SHA (on: push) and create the scoped run
createRepoWorkflowFile(t, user2, user2Token, consumer, "m2.txt", "trigger")
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, Event: "push"}),
"after the source switches to on: push, the next consumer push creates a scoped run")
})
t.Run("Deletion cleans up source registration", func(t *testing.T) {
source := createTestRepo(t, "sw-delete-source", false)
addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add", map[string]string{"repo_name": source.Name})
user2Session.MakeRequest(t, addReq, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
delReq := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s", source.OwnerName, source.Name)).AddTokenAuth(user2Token)
MakeRequest(t, delReq, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &actions_model.ActionScopedWorkflowSource{SourceRepoID: source.ID})
})
})
}
@@ -9,10 +9,10 @@ import (
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
@@ -12,30 +12,30 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/commitstatus"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/timeutil"
webhook_module "code.gitea.io/gitea/modules/webhook"
issue_service "code.gitea.io/gitea/services/issue"
pull_service "code.gitea.io/gitea/services/pull"
release_service "code.gitea.io/gitea/services/release"
repo_service "code.gitea.io/gitea/services/repository"
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
files_service "code.gitea.io/gitea/services/repository/files"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
webhook_module "gitea.dev/modules/webhook"
issue_service "gitea.dev/services/issue"
pull_service "gitea.dev/services/pull"
release_service "gitea.dev/services/release"
repo_service "gitea.dev/services/repository"
commitstatus_service "gitea.dev/services/repository/commitstatus"
files_service "gitea.dev/services/repository/files"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -166,6 +166,14 @@ jobs:
assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
// require the workflow's status check on the base branch, so a filtered-out run posts a skipped status to satisfy it
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), baseRepo, &git_model.ProtectedBranch{
RepoID: baseRepo.ID,
RuleName: "main",
EnableStatusCheck: true,
StatusCheckContexts: []string{"*"},
}, git_model.WhitelistOptions{}))
// add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
@@ -215,8 +223,68 @@ jobs:
err = pull_service.NewPullRequest(t.Context(), prOpts)
assert.NoError(t, err)
// the new pull request cannot trigger actions, so there is still only 1 record
// the new pull request is filtered by paths, so no run is created; a skipped commit status is posted instead
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
assertSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
// delete the branch protection rule: with nothing required, a filtered-out run must post no skipped status
pb, err := git_model.GetProtectedBranchRuleByName(t.Context(), baseRepo.ID, "main")
require.NoError(t, err)
require.NotNil(t, pb)
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), baseRepo, pb.ID))
// add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "bar.txt",
ContentReader: strings.NewReader("bar"),
},
},
Message: "add bar.txt",
OldBranch: "main",
NewBranch: "fork-branch-3",
Author: &files_service.IdentityOptions{
GitUserName: user4.Name,
GitUserEmail: user4.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: user4.Name,
GitUserEmail: user4.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
assert.NoError(t, err)
assert.NotEmpty(t, addFileToForkedResp)
// create Pull
pullIssue = &issues_model.Issue{
RepoID: baseRepo.ID,
Title: "A mismatched path with no required status check posts no skipped status",
PosterID: user4.ID,
Poster: user4,
IsPull: true,
}
pullRequest = &issues_model.PullRequest{
HeadRepoID: forkedRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "fork-branch-3",
BaseBranch: "main",
HeadRepo: forkedRepo,
BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea,
}
prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
err = pull_service.NewPullRequest(t.Context(), prOpts)
assert.NoError(t, err)
// filtered by paths and no required status check remains, so no run and no skipped commit status
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
assertNoSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
})
}
@@ -338,6 +406,10 @@ jobs:
})
assert.NoError(t, err)
assert.NotEmpty(t, addFileToBranchResp)
// the push to test-skip-ci is filtered by branches, so no run is created;
// its context is not a required status check either, so no skipped commit status is posted.
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
assertNoSkippedCommitStatusExists(t, repo.ID, addFileToBranchResp.Commit.SHA, "push")
resp := testPullCreate(t, session, "user2", "skip-ci", true, "master", "test-skip-ci", "[skip ci] test-skip-ci")
@@ -345,7 +417,7 @@ jobs:
url := test.RedirectURL(resp)
assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
// the pr title contains a configured skip-ci string, so there is still only 1 record
// the pr title contains a configured skip-ci string, so no run and no skipped status are created
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
}
@@ -638,7 +710,7 @@ jobs:
return false
}
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
return true
}
return false
@@ -680,14 +752,18 @@ func checkCommitStatusAndInsertFakeStatus(t *testing.T, repo *repo_model.Reposit
assert.Len(t, latestCommitStatuses, 1)
assert.Equal(t, commitstatus.CommitStatusPending, latestCommitStatuses[0].State)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
}
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL, context string) {
// insertFakeStatus inserts a success status that lands in the same dedupe
// group as `prev` — the actions runner mixes the workflow file path into
// ContextHash, so we must reuse it (rather than recomputing from Context).
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha string, prev *git_model.CommitStatus) {
err := commitstatus_service.CreateCommitStatus(t.Context(), repo, user_model.NewActionsUser(), sha, &git_model.CommitStatus{
State: commitstatus.CommitStatusSuccess,
TargetURL: targetURL,
Context: context,
State: commitstatus.CommitStatusSuccess,
TargetURL: prev.TargetURL,
Context: prev.Context,
ContextHash: prev.ContextHash,
})
assert.NoError(t, err)
}
@@ -822,7 +898,7 @@ jobs:
return false
}
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
return true
}
return false
@@ -931,6 +1007,76 @@ jobs:
})
}
func TestWorkflowDispatchPublicApiRequiresWorkflowDispatchTrigger(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
repo, err := repo_service.CreateRepository(t.Context(), user2, user2, repo_service.CreateRepoOptions{
Name: "workflow-dispatch-requires-trigger",
Description: "test workflow dispatch requires workflow_dispatch",
AutoInit: true,
Gitignores: "Go",
License: "MIT",
Readme: "Default",
DefaultBranch: "main",
IsPrivate: false,
})
require.NoError(t, err)
require.NotNil(t, repo)
addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(t.Context(), repo, user2, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/push-only.yml",
ContentReader: strings.NewReader(`
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo helloworld
`),
},
},
Message: "add workflow",
OldBranch: "main",
NewBranch: "main",
Author: &files_service.IdentityOptions{
GitUserName: user2.Name,
GitUserEmail: user2.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: user2.Name,
GitUserEmail: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
require.NotNil(t, addWorkflowToBaseResp)
values := url.Values{}
values.Set("ref", "main")
req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/push-only.yml/dispatches", repo.FullName()), values).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusUnprocessableEntity)
apiError := DecodeJSON(t, resp, &api.APIError{})
assert.Contains(t, apiError.Message, "has no workflow_dispatch event trigger")
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{
RepoID: repo.ID,
Event: "workflow_dispatch",
WorkflowID: "push-only.yml",
})
})
}
func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
@@ -1568,8 +1714,7 @@ jobs:
Name: new("close-pull-request-with-path-fork"),
}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
var apiForkRepo api.Repository
DecodeJSON(t, resp, &apiForkRepo)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
@@ -1802,7 +1947,33 @@ jobs:
testEditFile(t, session, "user2", repoName, repo.DefaultBranch, "dir1/dir1.txt", "11")
// update by rebase
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/pulls/%d/update?style=rebase", "user2", repoName, apiPull.Index))
session.MakeRequest(t, req, http.StatusSeeOther)
session.MakeRequest(t, req, http.StatusOK)
runner.fetchNoTask(t)
})
}
// assertSkippedCommitStatusExists asserts that a filtered-out required workflow posted a skipped commit status on sha
func assertSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
t.Helper()
assert.Truef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "missing skipped commit status with event %q on %s", eventSuffix, sha)
}
// assertNoSkippedCommitStatusExists asserts that no skipped commit status for the given event was posted on sha,
// e.g. a filtered-out workflow whose context is not a required status check must not leave one behind.
func assertNoSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
t.Helper()
assert.Falsef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "unexpected skipped commit status with event %q on %s", eventSuffix, sha)
}
// hasSkippedCommitStatus reports whether a skipped commit status for the given event was posted on sha.
func hasSkippedCommitStatus(t *testing.T, repoID int64, sha, eventSuffix string) bool {
t.Helper()
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repoID, sha, db.ListOptionsAll)
require.NoError(t, err)
for _, s := range statuses {
if s.State == commitstatus.CommitStatusSkipped && strings.Contains(s.Context, "("+eventSuffix+")") {
return true
}
}
return false
}
@@ -8,12 +8,12 @@ import (
"net/http"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -7,11 +7,11 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/setting/config"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
"gitea.dev/models/system"
"gitea.dev/modules/setting"
"gitea.dev/modules/setting/config"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -21,29 +21,52 @@ func TestAdminConfig(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
req := NewRequest(t, "GET", "/-/admin/config")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
t.Run("ConfigPage", func(t *testing.T) {
req := NewRequest(t, "GET", "/-/admin/config")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
})
t.Run("OpenEditorWithApps", func(t *testing.T) {
cfg := setting.Config().Repository.OpenWithEditorApps
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 3)
assert.False(t, cfg.HasValue(t.Context()))
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[]"}))
config.GetDynGetter().InvalidateCache()
t.Run("Default", func(t *testing.T) {
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 3)
assert.False(t, cfg.HasValue(t.Context()))
})
editorApps = cfg.Value(t.Context())
assert.Len(t, editorApps, 3)
assert.False(t, cfg.HasValue(t.Context()))
t.Run("EmptyAsDefault", func(t *testing.T) {
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[]"}))
config.GetDynGetter().InvalidateCache()
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[{}]"}))
config.GetDynGetter().InvalidateCache()
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 3)
assert.False(t, cfg.HasValue(t.Context()))
})
editorApps = cfg.Value(t.Context())
assert.Len(t, editorApps, 1)
assert.True(t, cfg.HasValue(t.Context()))
t.Run("SingleItem", func(t *testing.T) {
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[{}]"}))
config.GetDynGetter().InvalidateCache()
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 1)
assert.True(t, cfg.HasValue(t.Context()))
})
t.Run("ManualSet", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
"key": "repository.open-with.editor-apps",
"value": `[{"DisplayName":"app-name","OpenURL":"my-app:?u={url}"}]`,
})
session.MakeRequest(t, req, http.StatusOK)
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 1)
assert.Equal(t, "app-name", editorApps[0].DisplayName)
assert.Equal(t, "my-app:?u={url}", editorApps[0].OpenURL)
assert.True(t, cfg.HasValue(t.Context()))
})
})
t.Run("InstanceWebBanner", func(t *testing.T) {
@@ -51,7 +74,7 @@ func TestAdminConfig(t *testing.T) {
assert.False(t, has)
assert.Equal(t, setting.WebBannerType{}, banner)
req = NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
req := NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
"key": "instance.web_banner",
"value": `{"DisplayEnabled":true,"ContentMessage":"test-msg","StartTimeUnix":123,"EndTimeUnix":456}`,
})

Some files were not shown because too many files have changed in this diff Show More