Introduce ActionRunAttempt to represent each execution of a run (#37119)

This PR introduces a new `ActionRunAttempt` model and makes Actions
execution attempt-scoped.

**Main Changes**

- Each workflow run trigger generates a new `ActionRunAttempt`. The
triggered jobs are then associated with this new `ActionRunAttempt`
record.
- Each rerun now creates:
  - a new `ActionRunAttempt` record for the workflow run
- a full new set of `ActionRunJob` records for the new
`ActionRunAttempt`
- For jobs that need to be rerun, the new job records are created as
runnable jobs in the new attempt.
- For jobs that do not need to be rerun, new job records are still
created in the new attempt, but they reuse the result of the previous
attempt instead of executing again.
- Introduce `rerunPlan` to manage each rerun and refactored rerun flow
into a two-phase plan-based model:
  - `buildRerunPlan`
  - `execRerunPlan`
- `RerunFailedWorkflowRun` and `RerunFailed` no longer directly derives
all jobs that need to be rerun; this step is now handled by
`buildRerunPlan`.
- Converted artifacts from run-scoped to attempt-scoped:
  - uploads are now associated with `RunAttemptID`
  - listing, download, and deletion resolve against the current attempt
- Added attempt-aware web Actions views:
- the default run page shows the latest attempt
(`/actions/runs/{run_id}`)
- previous attempt pages show jobs and artifacts for that attempt
(`/actions/runs/{run_id}/attempts/{attempt_num}`)
- New APIs:
  - `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}`
  - `/repos/{owner}/{repo}/actions/runs/{run}/attempts/{attempt}/jobs`
- New configuration `MAX_RERUN_ATTEMPTS`
  - https://gitea.com/gitea/docs/pulls/383

**Compatibility**

- Existing legacy runs use `LatestAttemptID = 0` and legacy jobs use
`RunAttemptID = 0`. Therefore, these fields can be used to identify
legacy runs and jobs and provide backward compatibility.
- If a legacy run is rerun, an `ActionRunAttempt` with `attempt=1` will
be created to represent the original execution. Then a new
`ActionRunAttempt` with `attempt=2` will be created for the real rerun.
- Existing artifact records are not backfilled; legacy artifacts
continue to use `RunAttemptID = 0`.

**Improvements**

- It is now easier to inspect and download logs from previous attempts.
-
[`run_attempt`](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context)
semantics are now aligned with GitHub.
- > A unique number for each attempt of a particular workflow run in a
repository. This number begins at 1 for the workflow run's first
attempt, and increments with each re-run.
- Rerun behavior is now clearer and more explicit.
- Instead of mutating the status of previous jobs in place, each rerun
creates a new attempt with a full new set of job records.
- Artifacts produced by different reruns can now be listed separately.

Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Zettat123
2026-04-23 17:33:41 -06:00
committed by GitHub
parent aedf4e84f5
commit 899ede1d55
74 changed files with 3838 additions and 848 deletions

View File

@@ -3,17 +3,19 @@ import {normalizeTestHtml} from '../utils/testhelper.ts';
describe('buildArtifactTooltipHtml', () => {
test('active artifact', () => {
const expiresUnix = Date.UTC(2026, 2, 20, 12, 0, 0) / 1000;
const expiresLocal = new Date(expiresUnix * 1000).toLocaleString();
const result = buildArtifactTooltipHtml({
name: 'artifact.zip',
size: 1024 * 1024,
status: 'completed',
expiresUnix: Date.UTC(2026, 2, 20, 12, 0, 0) / 1000,
expiresUnix,
}, 'Expires at %s (extra)');
expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(`<span class="flex-text-inline">
<span>Expires at </span>
<relative-time datetime="2026-03-20T12:00:00.000Z" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
2026-03-20T12:00:00.000Z
<relative-time datetime="${expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${expiresLocal}
</relative-time>
<span> (extra)</span>
<span class="inline-divider">,</span>

View File

@@ -7,15 +7,14 @@ export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLoc
if (artifact.expiresUnix <= 0) {
return html`<span class="flex-text-inline">${sizeText}</span>`; // use the same layout as below
}
const datetimeLocal = new Date(artifact.expiresUnix * 1000).toLocaleString();
// split so the <relative-time> element can be interleaved, e.g. "Expires at %s" -> ["Expires at ", ""]
const [prefix, suffix = ''] = expiresAtLocale.split('%s');
const datetime = new Date(artifact.expiresUnix * 1000).toISOString();
return html`
<span class="flex-text-inline">
<span>${prefix}</span>
<relative-time datetime="${datetime}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${datetime}
<relative-time datetime="${artifact.expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
${datetimeLocal}
</relative-time>
<span>${suffix}</span>
<span class="inline-divider">,</span>

View File

@@ -77,9 +77,8 @@ defineOptions({
const props = defineProps<{
store: ActionRunViewStore,
runId: number;
jobId: number;
actionsUrl: string;
actionsViewUrl: string;
locale: Record<string, any>;
}>();
const store = props.store;
@@ -270,8 +269,7 @@ async function fetchJobData(abortController: AbortController): Promise<JobData>
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
return {step: idx, cursor: it.cursor, expanded: it.expanded};
});
const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`;
const resp = await POST(url, {
const resp = await POST(props.actionsViewUrl, {
signal: abortController.signal,
data: {logCursors},
});

View File

@@ -13,11 +13,18 @@ const props = defineProps<{
locale: Record<string, any>;
}>();
const locale = props.locale;
const {currentRun: run} = toRefs(props.store.viewData);
const runTriggeredAtIso = computed(() => {
const t = props.store.viewData.currentRun.triggeredAt;
return t ? new Date(t * 1000).toISOString() : '';
const isRerun = computed(() => run.value.runAttempt > 1);
const triggerUser = computed(() => {
const currentAttempt = run.value.attempts.find((attempt) => attempt.current);
if (currentAttempt) {
return {name: currentAttempt.triggerUserName, link: currentAttempt.triggerUserLink};
}
const pusher = run.value.commit.pusher;
return pusher.displayName ? {name: pusher.displayName, link: pusher.link} : null;
});
onMounted(async () => {
@@ -32,7 +39,14 @@ onBeforeUnmount(() => {
<div class="action-run-summary-view">
<div class="action-run-summary-block">
<div class="flex-text-block">
{{ locale.triggeredVia.replace('%s', run.triggerEvent) }} <relative-time :datetime="runTriggeredAtIso" prefix=""/>
<span>{{ isRerun ? locale.rerun : locale.triggeredVia.replace('%s', run.triggerEvent) }}</span>
<template v-if="triggerUser">
<span></span>
<a v-if="triggerUser.link" class="muted" :href="triggerUser.link">{{ triggerUser.name }}</a>
<span v-else class="muted">{{ triggerUser.name }}</span>
</template>
<span></span>
<relative-time :datetime="run.triggeredAt || ''" prefix=""/>
</div>
<div class="flex-text-block">
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>

View File

@@ -91,6 +91,7 @@ export function createEmptyActionsRun(): ActionsRun {
return {
repoId: 0,
link: '',
viewLink: '',
title: '',
titleHTML: '',
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
@@ -103,6 +104,8 @@ export function createEmptyActionsRun(): ActionsRun {
workflowID: '',
workflowLink: '',
isSchedule: false,
runAttempt: 0,
attempts: [],
duration: '',
triggeredAt: 0,
triggerEvent: '',
@@ -125,7 +128,7 @@ export function createEmptyActionsRun(): ActionsRun {
};
}
export function createActionRunViewStore(actionsUrl: string, runId: number) {
export function createActionRunViewStore(viewUrl: string) {
let loadingAbortController: AbortController | null = null;
let intervalID: IntervalId | null = null;
const viewData = reactive({
@@ -137,8 +140,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) {
const abortController = new AbortController();
loadingAbortController = abortController;
try {
const url = `${actionsUrl}/runs/${runId}`;
const resp = await POST(url, {signal: abortController.signal, data: {}});
const resp = await POST(viewUrl, {signal: abortController.signal, data: {}});
const runResp = await resp.json();
if (loadingAbortController !== abortController) return;

View File

@@ -5,6 +5,7 @@ import {toRefs} from 'vue';
import {POST, DELETE} from '../modules/fetch.ts';
import ActionRunSummaryView from './ActionRunSummaryView.vue';
import ActionRunJobView from './ActionRunJobView.vue';
import type {ActionsRunAttempt} from '../modules/gitea-actions.ts';
import {createActionRunViewStore} from './ActionRunView.ts';
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
@@ -13,16 +14,28 @@ defineOptions({
});
const props = defineProps<{
runId: number;
jobId: number;
actionsUrl: string;
actionsViewUrl: string;
locale: Record<string, any>;
}>();
const locale = props.locale;
const store = createActionRunViewStore(props.actionsUrl, props.runId);
const store = createActionRunViewStore(props.actionsViewUrl);
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
function formatAttemptTitle(attempt: ActionsRunAttempt) {
return attempt.latest ? `${locale.latestAttempt} #${attempt.attempt}` : `${locale.attempt} #${attempt.attempt}`;
}
function formatCurrentAttemptTitle(attempt: ActionsRunAttempt) {
return attempt.latest ? `${locale.latest} #${attempt.attempt}` : formatAttemptTitle(attempt);
}
function buildArtifactLink(name: string) {
const searchString = run.value.runAttempt > 0 ? `?attempt=${run.value.runAttempt}` : '';
return `${run.value.link}/artifacts/${encodeURIComponent(name)}${searchString}`;
}
function cancelRun() {
POST(`${run.value.link}/cancel`);
}
@@ -33,7 +46,7 @@ function approveRun() {
async function deleteArtifact(name: string) {
if (!window.confirm(locale.confirmDeleteArtifact.replace('%s', name))) return;
await DELETE(`${run.value.link}/artifacts/${encodeURIComponent(name)}`);
await DELETE(buildArtifactLink(name));
await store.forceReloadCurrentRun();
}
</script>
@@ -51,10 +64,10 @@ async function deleteArtifact(name: string) {
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
{{ locale.approve }}
</button>
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
<button class="ui small compact button tw-text-red" @click="cancelRun()" v-else-if="run.canCancel">
{{ locale.cancel }}
</button>
<template v-else-if="run.canRerun">
<template v-if="run.canRerun">
<div v-if="run.canRerunFailed" class="ui small compact buttons">
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun-failed`">
{{ locale.rerun_failed }}
@@ -72,10 +85,45 @@ async function deleteArtifact(name: string) {
{{ locale.rerun_all }}
</button>
</template>
<div v-if="run.attempts.length > 1" class="ui dropdown basic small compact button">
<div class="flex-text-inline">
<SvgIcon name="octicon-history" :size="14"/>
<span>{{ formatCurrentAttemptTitle(run.attempts.find((attempt) => attempt.current)!) }}</span>
</div>
<SvgIcon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
<div class="menu">
<a
v-for="attempt in run.attempts"
:key="attempt.attempt"
class="item tw-flex tw-flex-col tw-gap-2"
:class="attempt.current ? 'selected' : ''"
:href="attempt.link"
>
<div class="flex-text-block">
<SvgIcon name="octicon-check" :size="14" :class="{'tw-invisible': !Boolean(attempt.current)}"/>
<strong class="tw-text-sm gt-ellipsis">{{ formatAttemptTitle(attempt) }}</strong>
</div>
<div class="flex-text-block tw-pl-[20px]">
<span class="flex-text-inline tw-flex-shrink-0">
<ActionRunStatus :locale-status="locale.status[attempt.status]" :status="attempt.status" :size="14" class="flex-text-block"/>
<span>{{ locale.status[attempt.status] }}</span>
</span>
<span></span>
<relative-time :datetime="attempt.triggeredAt" prefix=""/>
<span></span>
<span class="gt-ellipsis">{{ attempt.triggerUserName }}</span>
</div>
</a>
</div>
</div>
</div>
</div>
<div class="action-commit-summary">
<span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
<span>
<a v-if="run.workflowLink" class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>
<b v-else>{{ run.workflowID }}</b>
:
</span>
<template v-if="run.isSchedule">
{{ locale.scheduled }}
</template>
@@ -94,7 +142,7 @@ async function deleteArtifact(name: string) {
<div class="action-view-body">
<div class="action-view-left">
<!-- summary -->
<a class="job-brief-item silenced" :href="run.link" :class="!props.jobId ? 'selected' : ''">
<a class="job-brief-item silenced" :href="run.viewLink" :class="!props.jobId ? 'selected' : ''">
<SvgIcon name="octicon-home"/>
<span class="gt-ellipsis">{{ locale.summary }}</span>
</a>
@@ -105,7 +153,7 @@ async function deleteArtifact(name: string) {
<!-- unlike other lists, the items have paddings already -->
<ul class="ui relaxed list flex-items-block tw-p-0">
<li class="item job-brief-item" v-for="job in run.jobs" :key="job.id" :class="props.jobId === job.id ? 'selected' : ''">
<a class="tw-contents silenced" :href="run.link+'/jobs/'+job.id">
<a class="tw-contents silenced" :href="job.link">
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
<span class="tw-flex-1 gt-ellipsis">{{ job.name }}</span>
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${job.id}/rerun`" v-if="job.canRerun"/>
@@ -123,7 +171,7 @@ async function deleteArtifact(name: string) {
<template v-if="artifact.status !== 'expired'">
<a
class="tw-flex-1 flex-text-block muted" target="_blank"
:href="run.link+'/artifacts/'+encodeURIComponent(artifact.name)"
:href="buildArtifactLink(artifact.name)"
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
data-tooltip-render="html"
data-tooltip-placement="top-end"
@@ -167,9 +215,8 @@ async function deleteArtifact(name: string) {
v-else
:store="store"
:locale="locale"
:run-id="props.runId"
:actions-view-url="props.actionsViewUrl"
:job-id="props.jobId"
:actions-url="props.actionsUrl"
/>
</div>
</div>

View File

@@ -4,8 +4,6 @@ import RepoActionView from '../components/RepoActionView.vue';
export function initRepositoryActionView() {
const el = document.querySelector('#repo-action-view');
if (!el) return;
const runId = parseInt(el.getAttribute('data-run-id')!);
const jobId = parseInt(el.getAttribute('data-job-id')!);
// TODO: the parent element's full height doesn't work well now,
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
@@ -13,25 +11,25 @@ export function initRepositoryActionView() {
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
const view = createApp(RepoActionView, {
runId,
jobId,
actionsUrl: el.getAttribute('data-actions-url'),
jobId: parseInt(el.getAttribute('data-job-id')!),
actionsViewUrl: el.getAttribute('data-actions-view-url'),
locale: {
approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'),
rerun_all: el.getAttribute('data-locale-rerun-all'),
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
latest: el.getAttribute('data-locale-latest'),
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
attempt: el.getAttribute('data-locale-attempt'),
scheduled: el.getAttribute('data-locale-runs-scheduled'),
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
summary: el.getAttribute('data-locale-summary'),
allJobs: el.getAttribute('data-locale-all-jobs'),
triggeredVia: el.getAttribute('data-locale-triggered-via'),
totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),

View File

@@ -5,6 +5,7 @@ export type ActionsArtifactStatus = 'expired' | 'completed';
export type ActionsRun = {
repoId: number,
link: string,
viewLink: string,
title: string,
titleHTML: string,
status: ActionsRunStatus,
@@ -17,6 +18,8 @@ export type ActionsRun = {
workflowID: string,
workflowLink: string,
isSchedule: boolean,
runAttempt: number,
attempts: Array<ActionsRunAttempt>,
duration: string,
triggeredAt: number,
triggerEvent: string,
@@ -38,8 +41,21 @@ export type ActionsRun = {
},
};
export type ActionsRunAttempt = {
attempt: number;
status: ActionsRunStatus;
done: boolean;
link: string;
current: boolean;
latest: boolean;
triggeredAt: number;
triggerUserName: string;
triggerUserLink: string;
};
export type ActionsJob = {
id: number;
link: string;
jobId: string;
name: string;
status: ActionsRunStatus;

View File

@@ -45,6 +45,7 @@ import octiconGitPullRequestClosed from '../../public/assets/img/svg/octicon-git
import octiconGitPullRequestDraft from '../../public/assets/img/svg/octicon-git-pull-request-draft.svg';
import octiconGrabber from '../../public/assets/img/svg/octicon-grabber.svg';
import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
import octiconHistory from '../../public/assets/img/svg/octicon-history.svg';
import octiconHorizontalRule from '../../public/assets/img/svg/octicon-horizontal-rule.svg';
import octiconHome from '../../public/assets/img/svg/octicon-home.svg';
import octiconImage from '../../public/assets/img/svg/octicon-image.svg';
@@ -131,6 +132,7 @@ const svgs = {
'octicon-git-pull-request-draft': octiconGitPullRequestDraft,
'octicon-grabber': octiconGrabber,
'octicon-heading': octiconHeading,
'octicon-history': octiconHistory,
'octicon-horizontal-rule': octiconHorizontalRule,
'octicon-home': octiconHome,
'octicon-image': octiconImage,

View File

@@ -54,6 +54,26 @@ test('switches to datetime format after default threshold', async () => {
expect(getText(el)).toMatch(/on [A-Z][a-z]{2} \d{1,2}/);
});
test('accepts unix seconds as integer string', async () => {
const el = createRelativeTime(String(Math.floor(Date.now() / 1000) - 3 * 60));
await Promise.resolve();
expect(getText(el)).toBe('3 minutes ago');
});
test('ignores fractional unix seconds', async () => {
const el = createRelativeTime('1700000000.5');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('ignores negative unix seconds', async () => {
const el = createRelativeTime('-86400');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('ignores invalid datetime', async () => {
const el = createRelativeTime('bogus');
el.shadowRoot!.textContent = 'fallback';
@@ -61,6 +81,13 @@ test('ignores invalid datetime', async () => {
expect(getText(el)).toBe('fallback');
});
test('ignores partial numeric datetime', async () => {
const el = createRelativeTime('123abc');
el.shadowRoot!.textContent = 'fallback';
await Promise.resolve();
expect(getText(el)).toBe('fallback');
});
test('handles empty datetime', async () => {
const el = createRelativeTime('');
el.shadowRoot!.textContent = 'fallback';

View File

@@ -28,6 +28,7 @@ type FormatStyle = 'long' | 'short' | 'narrow';
const unitNames = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] as const;
const durationRe = /^[-+]?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
const unixSecondsRe = /^\d+$/;
function parseDurationMs(str: string): number {
const m = durationRe.exec(str);
@@ -364,7 +365,8 @@ class RelativeTime extends HTMLElement {
}
get date(): Date | null {
const parsed = Date.parse(this.datetime);
const dt = this.datetime;
const parsed = unixSecondsRe.test(dt) ? Number(dt) * 1000 : Date.parse(dt);
return Number.isNaN(parsed) ? null : new Date(parsed);
}