mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-31 11:28:24 +00:00 
			
		
		
		
	Close #13539. Co-authored by: @lunny @appleboy @fuxiaohei and others. Related projects: - https://gitea.com/gitea/actions-proto-def - https://gitea.com/gitea/actions-proto-go - https://gitea.com/gitea/act - https://gitea.com/gitea/act_runner ### Summary The target of this PR is to bring a basic implementation of "Actions", an internal CI/CD system of Gitea. That means even though it has been merged, the state of the feature is **EXPERIMENTAL**, and please note that: - It is disabled by default; - It shouldn't be used in a production environment currently; - It shouldn't be used in a public Gitea instance currently; - Breaking changes may be made before it's stable. **Please comment on #13539 if you have any different product design ideas**, all decisions reached there will be adopted here. But in this PR, we don't talk about **naming, feature-creep or alternatives**. ### ⚠️ Breaking `gitea-actions` will become a reserved user name. If a user with the name already exists in the database, it is recommended to rename it. ### Some important reviews - What is `DEFAULT_ACTIONS_URL` in `app.ini` for? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1055954954 - Why the api for runners is not under the normal `/api/v1` prefix? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1061173592 - Why DBFS? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1061301178 - Why ignore events triggered by `gitea-actions` bot? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1063254103 - Why there's no permission control for actions? - https://github.com/go-gitea/gitea/pull/21937#discussion_r1090229868 ### What it looks like <details> #### Manage runners <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205870657-c72f590e-2e08-4cd4-be7f-2e0abb299bbf.png"> #### List runs <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205872794-50fde990-2b45-48c1-a178-908e4ec5b627.png"> #### View logs <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205872501-9b7b9000-9542-4991-8f55-18ccdada77c3.png"> </details> ### How to try it <details> #### 1. Start Gitea Clone this branch and [install from source](https://docs.gitea.io/en-us/install-from-source). Add additional configurations in `app.ini` to enable Actions: ```ini [actions] ENABLED = true ``` Start it. If all is well, you'll see the management page of runners: <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205877365-8e30a780-9b10-4154-b3e8-ee6c3cb35a59.png"> #### 2. Start runner Clone the [act_runner](https://gitea.com/gitea/act_runner), and follow the [README](https://gitea.com/gitea/act_runner/src/branch/main/README.md) to start it. If all is well, you'll see a new runner has been added: <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205878000-216f5937-e696-470d-b66c-8473987d91c3.png"> #### 3. Enable actions for a repo Create a new repo or open an existing one, check the `Actions` checkbox in settings and submit. <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205879705-53e09208-73c0-4b3e-a123-2dcf9aba4b9c.png"> <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205879383-23f3d08f-1a85-41dd-a8b3-54e2ee6453e8.png"> If all is well, you'll see a new tab "Actions": <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205881648-a8072d8c-5803-4d76-b8a8-9b2fb49516c1.png"> #### 4. Upload workflow files Upload some workflow files to `.gitea/workflows/xxx.yaml`, you can follow the [quickstart](https://docs.github.com/en/actions/quickstart) of GitHub Actions. Yes, Gitea Actions is compatible with GitHub Actions in most cases, you can use the same demo: ```yaml name: GitHub Actions Demo run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 on: [push] jobs: Explore-GitHub-Actions: runs-on: ubuntu-latest steps: - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." - name: Check out repository code uses: actions/checkout@v3 - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - run: echo "🖥️ The workflow is now ready to test your code on the runner." - name: List files in the repository run: | ls ${{ github.workspace }} - run: echo "🍏 This job's status is ${{ job.status }}." ``` If all is well, you'll see a new run in `Actions` tab: <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205884473-79a874bc-171b-4aaf-acd5-0241a45c3b53.png"> #### 5. Check the logs of jobs Click a run and you'll see the logs: <img width="1792" alt="image" src="https://user-images.githubusercontent.com/9418365/205884800-994b0374-67f7-48ff-be9a-4c53f3141547.png"> #### 6. Go on You can try more examples in [the documents](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) of GitHub Actions, then you might find a lot of bugs. Come on, PRs are welcome. </details> See also: [Feature Preview: Gitea Actions](https://blog.gitea.io/2022/12/feature-preview-gitea-actions/) --------- Co-authored-by: a1012112796 <1012112796@qq.com> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: delvh <dev.lh@web.de> Co-authored-by: ChristopherHX <christopher.homberger@web.de> Co-authored-by: John Olheiser <john.olheiser@gmail.com>
		
			
				
	
	
		
			399 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			399 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <div class="action-view-container">
 | |
|     <div class="action-view-header">
 | |
|       <div class="action-info-summary">
 | |
|         {{ run.title }}
 | |
|         <button class="run_cancel" @click="cancelRun()" v-if="run.canCancel">
 | |
|           <i class="stop circle outline icon"/>
 | |
|         </button>
 | |
|       </div>
 | |
|     </div>
 | |
|     <div class="action-view-body">
 | |
|       <div class="action-view-left">
 | |
|         <div class="job-group-section">
 | |
|           <div class="job-brief-list">
 | |
|             <a class="job-brief-item" v-for="(job, index) in run.jobs" :key="job.id" :href="run.htmlurl+'/jobs/'+index">
 | |
|               <SvgIcon name="octicon-check-circle-fill" class="green" v-if="job.status === 'success'"/>
 | |
|               <SvgIcon name="octicon-skip" class="ui text grey" v-else-if="job.status === 'skipped'"/>
 | |
|               <SvgIcon name="octicon-clock" class="ui text yellow" v-else-if="job.status === 'waiting'"/>
 | |
|               <SvgIcon name="octicon-blocked" class="ui text yellow" v-else-if="job.status === 'blocked'"/>
 | |
|               <SvgIcon name="octicon-meter" class="ui text yellow" class-name="job-status-rotate" v-else-if="job.status === 'running'"/>
 | |
|               <SvgIcon name="octicon-x-circle-fill" class="red" v-else/>
 | |
|               {{ job.name }}
 | |
|               <button class="job-brief-rerun" @click="rerunJob(index)" v-if="job.canRerun">
 | |
|                 <SvgIcon name="octicon-sync" class="ui text black"/>
 | |
|               </button>
 | |
|             </a>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
| 
 | |
|       <div class="action-view-right">
 | |
|         <div class="job-info-header">
 | |
|           <div class="job-info-header-title">
 | |
|             {{ currentJob.title }}
 | |
|           </div>
 | |
|           <div class="job-info-header-detail">
 | |
|             {{ currentJob.detail }}
 | |
|           </div>
 | |
|         </div>
 | |
|         <div class="job-step-container">
 | |
|           <div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
 | |
|             <div class="job-step-summary" @click.stop="toggleStepLogs(i)">
 | |
|               <SvgIcon name="octicon-chevron-down" class="mr-3" v-show="currentJobStepsStates[i].expanded"/>
 | |
|               <SvgIcon name="octicon-chevron-right" class="mr-3" v-show="!currentJobStepsStates[i].expanded"/>
 | |
| 
 | |
|               <SvgIcon name="octicon-check-circle-fill" class="green mr-3" v-if="jobStep.status === 'success'"/>
 | |
|               <SvgIcon name="octicon-skip" class="ui text grey mr-3" v-else-if="jobStep.status === 'skipped'"/>
 | |
|               <SvgIcon name="octicon-clock" class="ui text yellow mr-3" v-else-if="jobStep.status === 'waiting'"/>
 | |
|               <SvgIcon name="octicon-blocked" class="ui text yellow mr-3" v-else-if="jobStep.status === 'blocked'"/>
 | |
|               <SvgIcon name="octicon-meter" class="ui text yellow mr-3" class-name="job-status-rotate" v-else-if="jobStep.status === 'running'"/>
 | |
|               <SvgIcon name="octicon-x-circle-fill" class="red mr-3 " v-else/>
 | |
| 
 | |
|               <span class="step-summary-msg">{{ jobStep.summary }}</span>
 | |
|               <span class="step-summary-dur">{{ jobStep.duration }}</span>
 | |
|             </div>
 | |
| 
 | |
|             <!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM -->
 | |
|             <div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|     </div>
 | |
|   </div>
 | |
| </template>
 | |
| 
 | |
| <script>
 | |
| import {SvgIcon} from '../svg.js';
 | |
| import {createApp} from 'vue';
 | |
| import AnsiToHTML from 'ansi-to-html';
 | |
| 
 | |
| const {csrfToken} = window.config;
 | |
| 
 | |
| const sfc = {
 | |
|   name: 'RepoActionView',
 | |
|   components: {
 | |
|     SvgIcon,
 | |
|   },
 | |
|   props: {
 | |
|     runIndex: String,
 | |
|     jobIndex: String,
 | |
|     actionsURL: String,
 | |
|   },
 | |
| 
 | |
|   data() {
 | |
|     return {
 | |
|       ansiToHTML: new AnsiToHTML({escapeXML: true}),
 | |
| 
 | |
|       // internal state
 | |
|       loading: false,
 | |
|       intervalID: null,
 | |
|       currentJobStepsStates: [],
 | |
| 
 | |
|       // provided by backend
 | |
|       run: {
 | |
|         htmlurl: '',
 | |
|         title: '',
 | |
|         canCancel: false,
 | |
|         done: false,
 | |
|         jobs: [
 | |
|           // {
 | |
|           //   id: 0,
 | |
|           //   name: '',
 | |
|           //   status: '',
 | |
|           //   canRerun: false,
 | |
|           // },
 | |
|         ],
 | |
|       },
 | |
|       currentJob: {
 | |
|         title: '',
 | |
|         detail: '',
 | |
|         steps: [
 | |
|           // {
 | |
|           //   summary: '',
 | |
|           //   duration: '',
 | |
|           //   status: '',
 | |
|           // }
 | |
|         ],
 | |
|       },
 | |
|     };
 | |
|   },
 | |
| 
 | |
|   mounted() {
 | |
|     // load job data and then auto-reload periodically
 | |
|     this.loadJob();
 | |
|     this.intervalID = setInterval(this.loadJob, 1000);
 | |
|   },
 | |
| 
 | |
|   methods: {
 | |
|     // get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
 | |
|     getLogsContainer(idx) {
 | |
|       const el = this.$refs.logs[idx];
 | |
|       return el._stepLogsActiveContainer ?? el;
 | |
|     },
 | |
|     // begin a log group
 | |
|     beginLogGroup(idx) {
 | |
|       const el = this.$refs.logs[idx];
 | |
| 
 | |
|       const elJobLogGroup = document.createElement('div');
 | |
|       elJobLogGroup.classList.add('job-log-group');
 | |
| 
 | |
|       const elJobLogGroupSummary = document.createElement('div');
 | |
|       elJobLogGroupSummary.classList.add('job-log-group-summary');
 | |
| 
 | |
|       const elJobLogList = document.createElement('div');
 | |
|       elJobLogList.classList.add('job-log-list');
 | |
| 
 | |
|       elJobLogGroup.appendChild(elJobLogGroupSummary);
 | |
|       elJobLogGroup.appendChild(elJobLogList);
 | |
|       el._stepLogsActiveContainer = elJobLogList;
 | |
|     },
 | |
|     // end a log group
 | |
|     endLogGroup(idx) {
 | |
|       const el = this.$refs.logs[idx];
 | |
|       el._stepLogsActiveContainer = null;
 | |
|     },
 | |
| 
 | |
|     // show/hide the step logs for a step
 | |
|     toggleStepLogs(idx) {
 | |
|       this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
 | |
|       if (this.currentJobStepsStates[idx].expanded) {
 | |
|         this.loadJob(); // try to load the data immediately instead of waiting for next timer interval
 | |
|       }
 | |
|     },
 | |
|     // rerun a job
 | |
|     rerunJob(idx) {
 | |
|       this.fetch(`${this.run.htmlurl}/jobs/${idx}/rerun`);
 | |
|     },
 | |
|     // cancel a run
 | |
|     cancelRun() {
 | |
|       this.fetch(`${this.run.htmlurl}/cancel`);
 | |
|     },
 | |
| 
 | |
|     createLogLine(line) {
 | |
|       const div = document.createElement('div');
 | |
|       div.classList.add('job-log-line');
 | |
|       div._jobLogTime = line.timestamp;
 | |
| 
 | |
|       const lineNumber = document.createElement('div');
 | |
|       lineNumber.className = 'line-num';
 | |
|       lineNumber.innerText = line.index;
 | |
|       div.appendChild(lineNumber);
 | |
| 
 | |
|       // TODO: Support displaying time optionally
 | |
| 
 | |
|       const logMessage = document.createElement('div');
 | |
|       logMessage.className = 'log-msg';
 | |
|       logMessage.innerHTML = this.ansiToHTML.toHtml(line.message);
 | |
|       div.appendChild(logMessage);
 | |
| 
 | |
|       return div;
 | |
|     },
 | |
| 
 | |
|     appendLogs(stepIndex, logLines) {
 | |
|       for (const line of logLines) {
 | |
|         // TODO: group support: ##[group]GroupTitle , ##[endgroup]
 | |
|         const el = this.getLogsContainer(stepIndex);
 | |
|         el.append(this.createLogLine(line));
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     async fetchJob() {
 | |
|       const logCursors = this.currentJobStepsStates.map((it, idx) => {
 | |
|         // cursor is used to indicate the last position of the logs
 | |
|         // it's only used by backend, frontend just reads it and passes it back, it and can be any type.
 | |
|         // 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 resp = await this.fetch(
 | |
|         `${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`,
 | |
|         JSON.stringify({logCursors}),
 | |
|       );
 | |
|       return await resp.json();
 | |
|     },
 | |
| 
 | |
|     async loadJob() {
 | |
|       if (this.loading) return;
 | |
|       try {
 | |
|         this.loading = true;
 | |
| 
 | |
|         const response = await this.fetchJob();
 | |
| 
 | |
|         // save the state to Vue data, then the UI will be updated
 | |
|         this.run = response.state.run;
 | |
|         this.currentJob = response.state.currentJob;
 | |
| 
 | |
|         // sync the currentJobStepsStates to store the job step states
 | |
|         for (let i = 0; i < this.currentJob.steps.length; i++) {
 | |
|           if (!this.currentJobStepsStates[i]) {
 | |
|             this.currentJobStepsStates[i] = {cursor: null, expanded: false};
 | |
|           }
 | |
|         }
 | |
|         // append logs to the UI
 | |
|         for (const logs of response.logs.stepsLog) {
 | |
|           // save the cursor, it will be passed to backend next time
 | |
|           this.currentJobStepsStates[logs.step].cursor = logs.cursor;
 | |
|           this.appendLogs(logs.step, logs.lines);
 | |
|         }
 | |
| 
 | |
|         if (this.run.done && this.intervalID) {
 | |
|           clearInterval(this.intervalID);
 | |
|           this.intervalID = null;
 | |
|         }
 | |
|       } finally {
 | |
|         this.loading = false;
 | |
|       }
 | |
|     },
 | |
| 
 | |
|     fetch(url, body) {
 | |
|       return fetch(url, {
 | |
|         method: 'POST',
 | |
|         headers: {
 | |
|           'Content-Type': 'application/json',
 | |
|           'X-Csrf-Token': csrfToken,
 | |
|         },
 | |
|         body,
 | |
|       });
 | |
|     },
 | |
|   },
 | |
| };
 | |
| 
 | |
| export default sfc;
 | |
| 
 | |
| export function initRepositoryActionView() {
 | |
|   const el = document.getElementById('repo-action-view');
 | |
|   if (!el) return;
 | |
| 
 | |
|   const view = createApp(sfc, {
 | |
|     runIndex: el.getAttribute('data-run-index'),
 | |
|     jobIndex: el.getAttribute('data-job-index'),
 | |
|     actionsURL: el.getAttribute('data-actions-url'),
 | |
|   });
 | |
|   view.mount(el);
 | |
| }
 | |
| 
 | |
| </script>
 | |
| 
 | |
| <style scoped lang="less">
 | |
| 
 | |
| // some elements are not managed by vue, so we need to use _actions.less in addition.
 | |
| 
 | |
| .action-view-body {
 | |
|   display: flex;
 | |
|   height: calc(100vh - 266px); // fine tune this value to make the main view has full height
 | |
| }
 | |
| 
 | |
| // ================
 | |
| // action view header
 | |
| 
 | |
| .action-view-header {
 | |
|   margin: 0 20px 20px 20px;
 | |
|   button.run_cancel {
 | |
|     border: none;
 | |
|     color: var(--color-red);
 | |
|     background-color: transparent;
 | |
|     outline: none;
 | |
|     cursor: pointer;
 | |
|     transition:transform 0.2s;
 | |
|   };
 | |
|   button.run_cancel:hover{
 | |
|     transform:scale(130%);
 | |
|   };
 | |
| }
 | |
| 
 | |
| .action-info-summary {
 | |
|   font-size: 150%;
 | |
|   height: 20px;
 | |
|   padding: 0 10px;
 | |
| }
 | |
| 
 | |
| // ================
 | |
| // action view left
 | |
| 
 | |
| .action-view-left {
 | |
|   width: 30%;
 | |
|   max-width: 400px;
 | |
|   overflow-y: scroll;
 | |
|   margin-left: 10px;
 | |
| }
 | |
| 
 | |
| .job-group-section {
 | |
|   .job-group-summary {
 | |
|     margin: 5px 0;
 | |
|     padding: 10px;
 | |
|   }
 | |
| 
 | |
|   .job-brief-list {
 | |
|     a.job-brief-item {
 | |
|       display: block;
 | |
|       margin: 5px 0;
 | |
|       padding: 10px;
 | |
|       background: var(--color-info-bg);
 | |
|       border-radius: 5px;
 | |
|       text-decoration: none;
 | |
|       button.job-brief-rerun {
 | |
|         float: right;
 | |
|         border: none;
 | |
|         background-color: transparent;
 | |
|         outline: none;
 | |
|         cursor: pointer;
 | |
|         transition:transform 0.2s;
 | |
|       };
 | |
|       button.job-brief-rerun:hover{
 | |
|         transform:scale(130%);
 | |
|       };
 | |
|     }
 | |
|     a.job-brief-item:hover {
 | |
|       background-color: var(--color-secondary);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| // ================
 | |
| // action view right
 | |
| 
 | |
| .action-view-right {
 | |
|   flex: 1;
 | |
|   background-color: var(--color-console-bg);
 | |
|   color: var(--color-console-fg);
 | |
|   max-height: 100%;
 | |
|   margin-right: 10px;
 | |
| 
 | |
|   display: flex;
 | |
|   flex-direction: column;
 | |
| }
 | |
| 
 | |
| .job-info-header {
 | |
|   .job-info-header-title {
 | |
|     font-size: 150%;
 | |
|     padding: 10px;
 | |
|   }
 | |
|   .job-info-header-detail {
 | |
|     padding: 0 10px 10px;
 | |
|     border-bottom: 1px solid var(--color-grey);
 | |
|   }
 | |
| }
 | |
| 
 | |
| .job-step-container {
 | |
|   max-height: 100%;
 | |
|   overflow: auto;
 | |
| 
 | |
|   .job-step-summary {
 | |
|     cursor: pointer;
 | |
|     padding: 5px 10px;
 | |
|     display: flex;
 | |
| 
 | |
|     .step-summary-msg {
 | |
|       flex: 1;
 | |
|     }
 | |
|     .step-summary-dur {
 | |
|       margin-left: 16px;
 | |
|     }
 | |
|   }
 | |
|   .job-step-summary:hover {
 | |
|     background-color: var(--color-black-light);
 | |
|   }
 | |
| }
 | |
| </style>
 | |
| 
 |