diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 0000000..9e78517
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,4 @@
+self-hosted-runner:
+ labels:
+ - t31-ci
+ - t31-deploy
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7e8bf78
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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"
diff --git a/deploy/apache-project1.conf b/deploy/apache-project1.conf
new file mode 100644
index 0000000..53e2bf4
--- /dev/null
+++ b/deploy/apache-project1.conf
@@ -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/
+
+
+ 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
+
+ Header set Cache-Control "no-cache"
+
+
diff --git a/scripts/deploy-project1.sh b/scripts/deploy-project1.sh
new file mode 100644
index 0000000..41111cc
--- /dev/null
+++ b/scripts/deploy-project1.sh
@@ -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"
diff --git a/scripts/test-deploy-project1.sh b/scripts/test-deploy-project1.sh
new file mode 100644
index 0000000..4e3edd2
--- /dev/null
+++ b/scripts/test-deploy-project1.sh
@@ -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 '\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'