Compare commits
6 Commits
ef119dc0dc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f413ee8e4c | |||
|
|
24cce8ea71 | ||
|
|
b7177c455e | ||
| 38ef683b69 | |||
|
|
8c46ef1972 | ||
|
|
0a1407719e |
@@ -9,14 +9,38 @@ on:
|
|||||||
# Pinned so every run targets the same project regardless of the runner's
|
# Pinned so every run targets the same project regardless of the runner's
|
||||||
# checkout path — otherwise `docker compose` would derive the project name
|
# checkout path — otherwise `docker compose` would derive the project name
|
||||||
# from the checkout directory, potentially spinning up a second stack and
|
# from the checkout directory, potentially spinning up a second stack and
|
||||||
# losing the `pgdata` volume instead of updating the running one.
|
# losing the `pgdata` volume instead of updating the running one. Must be
|
||||||
|
# `ruvdstest` (no trailing "s") to match the project name the live stack was
|
||||||
|
# actually deployed under — a one-letter mismatch here previously made
|
||||||
|
# Compose treat it as an unrelated project and spin up a whole second stack
|
||||||
|
# from scratch instead of recognizing and updating the running containers.
|
||||||
|
#
|
||||||
|
# JWT_SECRET/POSTGRES_PASSWORD come from Gitea's own Actions secrets store
|
||||||
|
# rather than the `.env` file DEPLOY.md has the human create in
|
||||||
|
# `/opt/ruvdstests` — the job checks out into the runner's own workspace, not
|
||||||
|
# that directory, so there's no `.env` for `docker compose` to read here.
|
||||||
|
# Must match the values already in that `.env` file: on `Deploy` this
|
||||||
|
# `docker compose up -d` targets the same running project (via
|
||||||
|
# COMPOSE_PROJECT_NAME above), and a different POSTGRES_PASSWORD than what
|
||||||
|
# the live `pgdata` volume was initialized with breaks the DB connection.
|
||||||
env:
|
env:
|
||||||
COMPOSE_PROJECT_NAME: ruvdstests
|
COMPOSE_PROJECT_NAME: ruvdstest
|
||||||
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-test-deploy:
|
build-test-deploy:
|
||||||
runs-on: host
|
runs-on: host
|
||||||
steps:
|
steps:
|
||||||
|
# `runs-on: host` runs every step directly on the bare runner
|
||||||
|
# container instead of spinning up a fresh container per job, and
|
||||||
|
# `actions/checkout` below is a JS action — it needs a `node` binary
|
||||||
|
# on PATH to run at all, which this image doesn't ship. Has to come
|
||||||
|
# before Checkout, since Checkout itself is what fails without it.
|
||||||
|
- name: Install Node.js
|
||||||
|
shell: bash
|
||||||
|
run: apk add --no-cache nodejs
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,25 @@ docker run -d --name gitea-ci-runner --restart unless-stopped \
|
|||||||
|
|
||||||
`host:host` — раннер выполняет джобы напрямую в своём собственном контейнере (никаких вложенных
|
`host:host` — раннер выполняет джобы напрямую в своём собственном контейнере (никаких вложенных
|
||||||
контейнеров на каждый джоб), у него смонтирован docker.sock хоста — поэтому `docker compose`
|
контейнеров на каждый джоб), у него смонтирован docker.sock хоста — поэтому `docker compose`
|
||||||
внутри джоба управляет реальными контейнерами на этой машине. Джоб сам ставит себе `docker-cli` и
|
внутри джоба управляет реальными контейнерами на этой машине. Джоб сам ставит себе `node`,
|
||||||
.NET SDK через `apk`/`dotnet-install.sh` (см. workflow) — образ раннера намеренно голый, чтобы не
|
`docker-cli` и .NET SDK через `apk`/`dotnet-install.sh` (см. workflow) — образ раннера намеренно
|
||||||
поддерживать отдельный кастомный образ.
|
голый, чтобы не поддерживать отдельный кастомный образ. `node` ставится первым шагом, до
|
||||||
|
`actions/checkout` — этот шаг сам является JS-экшеном и без `node` в PATH падает с `Cannot find:
|
||||||
|
node in PATH` раньше, чем успевает выполниться что-либо ещё.
|
||||||
|
|
||||||
Проверить, что раннер подключился: Site Administration → Actions → Runners — должен появиться
|
Проверить, что раннер подключился: Site Administration → Actions → Runners — должен появиться
|
||||||
`ruvdstest-prod` со статусом Idle/Online.
|
`ruvdstest-prod` со статусом Idle/Online.
|
||||||
|
|
||||||
|
## Секреты
|
||||||
|
|
||||||
|
Джоб чекаутит репозиторий во временную директорию раннера, а не в `/opt/ruvdstests` — своего
|
||||||
|
`.env` там нет, поэтому `JWT_SECRET`/`POSTGRES_PASSWORD` для `docker compose build`/`up` берутся не
|
||||||
|
из файла, а из хранилища секретов самой Gitea Actions: Settings репозитория → Actions → Secrets →
|
||||||
|
**Add Secret**. Завести `JWT_SECRET` и `POSTGRES_PASSWORD` со **значениями, совпадающими с тем, что
|
||||||
|
уже лежит в `/opt/ruvdstests/.env`** на сервере (`cat /opt/ruvdstests/.env` на сервере, скопировать
|
||||||
|
оттуда) — деплой-шаг пересоздаёт тот же самый запущенный проект (`COMPOSE_PROJECT_NAME` выше), и
|
||||||
|
рассинхронизация паролей с уже инициализированным `pgdata`-volume сломает подключение к БД.
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
Сделайте любой коммит и запушьте в `master` — во вкладке **Actions** репозитория должен появиться
|
Сделайте любой коммит и запушьте в `master` — во вкладке **Actions** репозитория должен появиться
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ let private kindLabel (t: QuestionTypeView) =
|
|||||||
| ShortAnswerT _ -> "Короткий ответ"
|
| ShortAnswerT _ -> "Короткий ответ"
|
||||||
| NumericT _ -> "Числовой"
|
| NumericT _ -> "Числовой"
|
||||||
|
|
||||||
|
/// Read-only rendering of a question's correct answer(s) for the teacher's
|
||||||
|
/// question-bank table — distinct from `formFromSummary` in Types.fs, which
|
||||||
|
/// unpacks the same data to prefill the edit form instead of displaying it.
|
||||||
|
let private correctAnswerText (t: QuestionTypeView) =
|
||||||
|
match t with
|
||||||
|
| SingleChoiceT d ->
|
||||||
|
d.Options
|
||||||
|
|> List.tryFind (fun o -> o.Id = d.CorrectOptionId)
|
||||||
|
|> Option.map (fun o -> o.Text)
|
||||||
|
|> Option.defaultValue "—"
|
||||||
|
| MultipleChoiceT d ->
|
||||||
|
d.Options
|
||||||
|
|> List.filter (fun o -> List.contains o.Id d.CorrectOptionIds)
|
||||||
|
|> List.map (fun o -> o.Text)
|
||||||
|
|> String.concat ", "
|
||||||
|
| TrueFalseT b -> if b then "Верно" else "Неверно"
|
||||||
|
| ShortAnswerT d -> String.concat ", " d.AcceptedAnswers
|
||||||
|
| NumericT d -> sprintf "%s ± %s" (Format.points d.CorrectValue) (Format.points d.Tolerance)
|
||||||
|
|
||||||
let private questionsListView (model: Model) dispatch =
|
let private questionsListView (model: Model) dispatch =
|
||||||
Html.div [
|
Html.div [
|
||||||
prop.className "questions-list"
|
prop.className "questions-list"
|
||||||
@@ -78,18 +97,30 @@ let private questionsListView (model: Model) dispatch =
|
|||||||
elif model.Questions.IsEmpty then
|
elif model.Questions.IsEmpty then
|
||||||
Html.p "В этой теме пока нет вопросов"
|
Html.p "В этой теме пока нет вопросов"
|
||||||
else
|
else
|
||||||
Html.ul [
|
Html.table [
|
||||||
|
prop.className "questions-table"
|
||||||
prop.children [
|
prop.children [
|
||||||
|
Html.thead [
|
||||||
|
Html.tr [
|
||||||
|
Html.th [ prop.text "Текст вопроса" ]
|
||||||
|
Html.th [ prop.text "Тип" ]
|
||||||
|
Html.th [ prop.text "Правильный ответ" ]
|
||||||
|
Html.th [ prop.text "Баллы" ]
|
||||||
|
Html.th []
|
||||||
|
]
|
||||||
|
]
|
||||||
|
Html.tbody [
|
||||||
for q in model.Questions ->
|
for q in model.Questions ->
|
||||||
Html.li [
|
Html.tr [
|
||||||
prop.key (string q.Id)
|
prop.key (string q.Id)
|
||||||
prop.children [
|
prop.children [
|
||||||
Html.span [ prop.className "question-kind"; prop.text (kindLabel q.Type) ]
|
Html.td [ prop.text q.Text ]
|
||||||
Html.text (sprintf " %s " q.Text)
|
Html.td [ prop.className "question-kind"; prop.text (kindLabel q.Type) ]
|
||||||
Html.span [
|
Html.td [ prop.text (correctAnswerText q.Type) ]
|
||||||
prop.className "tag-mono"
|
Html.td [ prop.className "tag-mono"; prop.text (Format.points q.Points) ]
|
||||||
prop.text (sprintf "%s б." (Format.points q.Points))
|
Html.td [
|
||||||
]
|
prop.className "question-actions-cell"
|
||||||
|
prop.children [
|
||||||
Html.button [
|
Html.button [
|
||||||
prop.type'.button
|
prop.type'.button
|
||||||
prop.onClick (fun _ -> dispatch (StartEditQuestion q.Id))
|
prop.onClick (fun _ -> dispatch (StartEditQuestion q.Id))
|
||||||
@@ -106,6 +137,9 @@ let private questionsListView (model: Model) dispatch =
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
let private optionsEditor (form: NewQuestionForm) dispatch (multi: bool) =
|
let private optionsEditor (form: NewQuestionForm) dispatch (multi: bool) =
|
||||||
Html.div [
|
Html.div [
|
||||||
|
|||||||
@@ -681,17 +681,44 @@ a {
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.questions-list li {
|
.questions-table {
|
||||||
display: flex;
|
width: 100%;
|
||||||
flex-wrap: wrap;
|
border-collapse: collapse;
|
||||||
align-items: center;
|
font-size: 0.9rem;
|
||||||
gap: 0.5rem;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
padding: 0.6rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.questions-list li:first-child {
|
.questions-table th,
|
||||||
border-top: none;
|
.questions-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questions-table th {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.questions-table tbody tr:hover {
|
||||||
|
background: var(--brass-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `width: 1%` shrinks this column to just its buttons' content width —
|
||||||
|
since it's the only unconstrained column left, the question-text column
|
||||||
|
absorbs 100% of the remaining space instead, so the buttons stay pinned
|
||||||
|
flush right regardless of how long any question's text is. */
|
||||||
|
.question-actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 1%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-actions-cell button {
|
||||||
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.question-kind {
|
.question-kind {
|
||||||
@@ -700,7 +727,6 @@ a {
|
|||||||
color: var(--brass);
|
color: var(--brass);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-question-form {
|
.new-question-form {
|
||||||
|
|||||||
Reference in New Issue
Block a user