Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
self-hosted-runner:
labels:
- t31-ci
- t31-deploy
120 changes: 120 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: CI

on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# la main bli ferdig, også hvis vi må rulle tilbake
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

defaults:
run:
shell: bash

jobs:
quality:
name: Quality checks
runs-on: [self-hosted, linux, x64, t31-ci]
timeout-minutes: 30
env:
CI: 'true'
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version

- name: Use the pinned npm version
run: npm install --global npm@11.11.1

- name: Install dependencies
run: npm ci

- name: Check code, contracts, migration metadata, tests and builds
run: npm run check

- name: Test release switching and rollback
run: bash scripts/test-deploy-project1.sh

- name: Install test browsers
run: npm run test:e2e:install

- name: Run browser checks
run: npm run test:e2e -- --workers=2

- name: Preserve browser failure evidence
if: failure()
# denne node20-versjonen finnes på git.ntnu.no og støtter enterprise
uses: actions/upload-artifact@v3.2.1-node20
with:
name: browser-results-${{ github.run_id }}-${{ github.run_attempt }}
path: test-results/
retention-days: 7

- name: Save the tested production build
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
uses: actions/upload-artifact@v3.2.1-node20
with:
name: project1-${{ github.sha }}-${{ github.run_attempt }}
path: web/dist/
if-no-files-found: error
retention-days: 7

deploy:
name: Deploy Project 1
needs: quality
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: [self-hosted, linux, x64, t31-deploy]
timeout-minutes: 10
environment:
name: production
url: http://it2810-31.idi.ntnu.no/project1/
concurrency:
group: project1-production
cancel-in-progress: false
steps:
- name: Check that this commit is still the tip of main
id: latest
uses: actions/github-script@v7
with:
script: |
const { data } = await github.rest.repos.getCommit({
...context.repo,
ref: 'main',
});
core.setOutput('current', data.sha === context.sha);
if (data.sha !== context.sha) {
core.notice('A newer commit is on main; skip this outdated deployment.');
}
- name: Check out the deployment script from the tested commit
if: steps.latest.outputs.current == 'true'
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Download the tested production build
if: steps.latest.outputs.current == 'true'
uses: actions/download-artifact@v3-node20
with:
name: project1-${{ github.sha }}-${{ github.run_attempt }}
path: web/dist/

- name: Publish to Apache and verify the release
if: steps.latest.outputs.current == 'true'
env:
RELEASE_ID: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
run: bash scripts/deploy-project1.sh web/dist "$RELEASE_ID"
15 changes: 15 additions & 0 deletions deploy/apache-project1.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# slå på med a2enconf t31-project1, sjekk configen og last apache på nytt
RedirectMatch 302 ^/project1$ /project1/
Alias /project1/ /srv/t31-project1/current/

<Directory /srv/t31-project1/>
Options +FollowSymLinks -Indexes -ExecCGI -Includes
AllowOverride None
Require all granted
DirectoryIndex index.html
# prosjekt 1 er bare statiske filer, ikke kjør noe backend her
SetHandler default-handler
<IfModule mod_headers.c>
Header set Cache-Control "no-cache"
</IfModule>
</Directory>
96 changes: 96 additions & 0 deletions scripts/deploy-project1.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# kjør som t31-deploy, uten sudo. apache leser fra current
# ryddet opp med hjelp fra codex
set -Eeuo pipefail
umask 022

fail() {
printf '%s\n' "$*" >&2
exit 1
}

check_input() {
[[ $release_id =~ ^[a-f0-9]{40}-[0-9]+-[0-9]+$ ]] || fail 'ugyldig release-id'
[[ -d $deploy_root/releases && -w $deploy_root ]] || fail 'deploy-mappa må settes opp først'
[[ -s $source_dir/index.html && -d $source_dir/assets ]] || fail 'bygget mangler index.html eller assets'
[[ -z $(find "$source_dir" -mindepth 1 ! -type f ! -type d -print -quit) ]] || fail 'bygget kan bare inneholde vanlige filer og mapper'
[[ ! -e $deploy_root/current || -L $deploy_root/current ]] || fail 'current må være en symlink'
[[ $health_url == http://* || $health_url == https://* ]] || fail 'sjekk-adressen må bruke http eller https'
}

# bytt hele siden på en gang, så ingen får et halvferdig bygg
set_current() {
rm -f -- "$work_dir/current"
ln -s -- "$1" "$work_dir/current"
mv -Tf -- "$work_dir/current" "$deploy_root/current"
}

cleanup() {
local status=$?
trap - EXIT HUP INT TERM

# noe feilet etter byttet? legg tilbake forrige versjon
if [[ $status != 0 && $switched == true ]]; then
if [[ -n $previous ]]; then
set_current "$previous"
printf 'deploy feilet, gikk tilbake til %s\n' "$previous" >&2
else
rm -- "$deploy_root/current"
printf 'første deploy feilet, fjernet current\n' >&2
fi
fi
rm -rf -- "$work_dir"
exit "$status"
}

prepare_release() {
mkdir "$work_dir/build"
cp -R -- "$source_dir/." "$work_dir/build/"
printf '%s\n' "$release_id" > "$work_dir/build/release.txt"
find "$work_dir/build" -type d -exec chmod 755 {} +
find "$work_dir/build" -type f -exec chmod 644 {} +
mv -T -- "$work_dir/build" "$release_dir"
}

# et 200-svar er ikke nok, apache må sende akkurat den fila vi bygde
check_file() {
local file=$1 url_path=${2-$1}
curl --fail --silent --show-error --noproxy '*' --connect-timeout 5 --max-time 15 \
"$health_url/$url_path" -o "$work_dir/response" || fail "fikk ikke hentet $file fra apache"
cmp -s -- "$release_dir/$file" "$work_dir/response" || fail "apache sender feil innhold for $file"
}

check_release() {
check_file release.txt
check_file index.html '' # sjekk selve /project1/, ikke bare index.html
find "$release_dir/assets" -type f -printf '%P\0' > "$work_dir/assets"
while IFS= read -r -d '' asset; do
[[ $asset =~ ^[A-Za-z0-9_./-]+$ ]] || fail 'uventet filnavn i assets'
check_file "assets/$asset"
done < "$work_dir/assets"
}

[[ $# == 2 ]] || fail 'bruk: deploy-project1.sh BYGGMAPPE RELEASE_ID'
source_dir=$(realpath -- "$1")
release_id=$2
deploy_root=${T31_DEPLOY_ROOT:-/srv/t31-project1}
health_url=${T31_DEPLOY_URL:-http://127.0.0.1/project1/}
health_url=${health_url%/}
check_input

# bare en deploy av gangen, også om noen kjører scriptet manuelt
exec 9>"$deploy_root/deploy.lock"
flock -w 60 9 || fail 'en annen deploy holder på'
release_dir=$deploy_root/releases/$release_id
[[ ! -e $release_dir ]] || fail 'denne releasen finnes fra før, start en ny kjøring'
previous=$(readlink "$deploy_root/current" || true)
work_dir=$(mktemp -d "$deploy_root/.deploy-XXXXXXXX")
switched=false
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

prepare_release
switched=true # rollback må være klar før vi bytter
set_current "$release_dir"
check_release
printf 'publisert %s på %s/\n' "$release_id" "$health_url"
99 changes: 99 additions & 0 deletions scripts/test-deploy-project1.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# sjekker deploy og rollback med en liten lokal webserver
set -Eeuo pipefail
repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
scratch=$(mktemp -d)
server_pid=''
cleanup() {
if [[ -n $server_pid ]]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
rm -rf -- "$scratch"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
mkdir -p "$scratch/site/releases" "$scratch/build/assets" "$scratch/http"
ln -s "$scratch/site/current" "$scratch/http/project1"
printf '<script src="/project1/assets/app.js"></script>\n' > "$scratch/build/index.html"
printf 'console.log("release one");\n' > "$scratch/build/assets/app.js"

python3 - "$scratch" <<'PY' &
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import sys
root = Path(sys.argv[1])
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(root / 'http'), **kwargs)
def do_GET(self):
if ((root / 'bad-index').exists() and self.path in ['/project1/', '/project1/index.html']) or (
(root / 'bad-asset').exists() and '/assets/' in self.path
):
self.send_response(200)
self.end_headers()
self.wfile.write(b'wrong release')
else:
super().do_GET()
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
(root / 'port').write_text(str(server.server_port))
server.serve_forever()
PY
server_pid=$!
for ((attempt=0; attempt<100; attempt++)); do
[[ -s $scratch/port ]] && break
sleep 0.05
done
[[ -s $scratch/port ]] || { printf 'HTTP server did not start.\n' >&2; exit 1; }

export T31_DEPLOY_ROOT=$scratch/site
T31_DEPLOY_URL=http://127.0.0.1:$(cat "$scratch/port")/project1/
export T31_DEPLOY_URL
first=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1-1
second=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2-1
failed=cccccccccccccccccccccccccccccccccccccccc-3-1
deploy() { bash "$repo_root/scripts/deploy-project1.sh" "$scratch/build" "$1"; }
expect_failure() {
if deploy "$1" > "$scratch/failure.log" 2>&1; then
printf 'Expected deployment %s to fail.\n' "$1" >&2
exit 1
fi
}

touch "$scratch/bad-index"
expect_failure "$failed"
[[ ! -e $scratch/site/current && ! -L $scratch/site/current ]]
rm "$scratch/bad-index"
deploy "$first"
[[ $(readlink "$scratch/site/current") == "$scratch/site/releases/$first" ]]
cmp "$scratch/build/assets/app.js" "$scratch/site/current/assets/app.js"
[[ $(stat -c '%a' "$scratch/site/current/index.html") == 644 ]]

printf 'console.log("release two");\n' > "$scratch/build/assets/app.js"
deploy "$second"
[[ -f $scratch/site/releases/$first/index.html ]]
[[ $(readlink "$scratch/site/current") == "$scratch/site/releases/$second" ]]
expect_failure "$second"
expect_failure '../../escape'
ln -s /etc/passwd "$scratch/build/assets/link"
expect_failure "${failed%1}2"
rm "$scratch/build/assets/link"

touch "$scratch/bad-index"
expect_failure "${failed%1}3"
[[ $(readlink "$scratch/site/current") == "$scratch/site/releases/$second" ]]
rm "$scratch/bad-index"
touch "$scratch/bad-asset"
expect_failure "${failed%1}4"
[[ $(readlink "$scratch/site/current") == "$scratch/site/releases/$second" ]]
rm "$scratch/bad-asset"
curl --fail --silent --noproxy '*' "$T31_DEPLOY_URL/release.txt" | cmp - "$scratch/site/current/release.txt"
[[ -z $(find "$scratch/site" -maxdepth 1 -name '.deploy-*' -print -quit) ]]
printf 'Deployment checks passed: first release, update, validation, index/asset rollback and cleanup.\n'
Loading