mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-31 11:28:24 +00:00 
			
		
		
		
	Implement actions (#21937)
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>
This commit is contained in:
		
							
								
								
									
										398
									
								
								web_src/js/components/RepoActionView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										398
									
								
								web_src/js/components/RepoActionView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,398 @@ | ||||
| <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> | ||||
|  | ||||
		Reference in New Issue
	
	Block a user