Publish the native release as a container image and document deployment
A static nginx image (unprivileged, read-only) of the production build, published to Gitea's registry for amd64 and arm64: version tags go to prod, manual dev-<commit> builds to dev, each smoke-tested first and optionally announced to Dockhand. The README describes running it with Docker Compose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d3e3faff72
commit
b33725adfa
9 files changed
+584
No files matched your search
@@ -0,0 +1,26 @@
|
||||
# Only the native application's build inputs may reach the Docker daemon.
|
||||
**
|
||||
!package.json
|
||||
!package-lock.json
|
||||
!tsconfig.json
|
||||
!vite.config.ts
|
||||
!index.html
|
||||
!src/
|
||||
!src/**
|
||||
!public/
|
||||
!public/**
|
||||
!scripts/
|
||||
!scripts/service-worker.mjs
|
||||
!scripts/check-release.mjs
|
||||
!deploy/
|
||||
!deploy/nginx.conf
|
||||
|
||||
# Also exclude private data accidentally placed inside an allowed directory.
|
||||
**/*.gb
|
||||
**/*.gbc
|
||||
**/*.rom
|
||||
**/*.sav
|
||||
**/*.crystal-save.json
|
||||
**/.env*
|
||||
**/node_modules
|
||||
**/.git
|
||||
@@ -0,0 +1,127 @@
|
||||
name: Publish container image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "dev-<commit SHA> (7-40 lowercase hex characters, matching the selected source)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: docker-build
|
||||
container:
|
||||
image: node:22-bookworm
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# Gitea does not implement GitHub's build-summary/artifact service.
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve and validate image tag
|
||||
id: image
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GIT_REF: ${{ github.ref }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: node scripts/image-publication.mjs
|
||||
|
||||
- name: Check registry credentials
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
test -n "$REGISTRY_USERNAME" || { echo 'Set the REGISTRY_USERNAME Actions secret.'; exit 1; }
|
||||
test -n "$REGISTRY_TOKEN" || { echo 'Set the REGISTRY_TOKEN Actions secret (write:package).'; exit 1; }
|
||||
|
||||
- name: Install Docker client and check daemon access
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates curl
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends docker-ce-cli
|
||||
docker info
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build an image for the runner's architecture
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: crystal-smoke:${{ github.run_id }}
|
||||
|
||||
- name: Check the container before publication
|
||||
env:
|
||||
SMOKE_IMAGE: crystal-smoke:${{ github.run_id }}
|
||||
run: |
|
||||
container_id=''
|
||||
trap '
|
||||
if [ -n "$container_id" ]; then docker rm -f "$container_id"; fi
|
||||
docker image rm "$SMOKE_IMAGE"
|
||||
' EXIT
|
||||
container_id=$(docker run -d --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges:true "$SMOKE_IMAGE")
|
||||
for _attempt in $(seq 1 30); do
|
||||
if docker exec "$container_id" wget -q -O /dev/null http://127.0.0.1:8080/; then
|
||||
docker exec "$container_id" wget -q -O /dev/null http://127.0.0.1:8080/sw.js
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
docker logs "$container_id"
|
||||
exit 1
|
||||
|
||||
- name: Log in to Gitea's container registry
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.image.outputs.registry }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and publish
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.image.outputs.image }}:${{ steps.image.outputs.tag }}
|
||||
${{ steps.image.outputs.image }}:${{ steps.image.outputs.channel }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Crystal
|
||||
org.opencontainers.image.source=${{ steps.image.outputs.source }}
|
||||
org.opencontainers.image.revision=${{ steps.image.outputs.revision }}
|
||||
org.opencontainers.image.version=${{ steps.image.outputs.tag }}
|
||||
|
||||
- name: Request production deployment in Dockhand
|
||||
if: steps.image.outputs.channel == 'prod'
|
||||
env:
|
||||
DOCKHAND_WEBHOOK_URL: ${{ secrets.DOCKHAND_PROD_WEBHOOK_URL }}
|
||||
DOCKHAND_WEBHOOK_SECRET: ${{ secrets.DOCKHAND_PROD_WEBHOOK_SECRET }}
|
||||
run: node scripts/notify-dockhand.mjs
|
||||
|
||||
- name: Request test deployment in Dockhand
|
||||
if: steps.image.outputs.channel == 'dev'
|
||||
env:
|
||||
DOCKHAND_WEBHOOK_URL: ${{ secrets.DOCKHAND_DEV_WEBHOOK_URL }}
|
||||
DOCKHAND_WEBHOOK_SECRET: ${{ secrets.DOCKHAND_DEV_WEBHOOK_SECRET }}
|
||||
run: node scripts/notify-dockhand.mjs
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# The output is static, so both target architectures share a native build.
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json vite.config.ts index.html ./
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
COPY scripts/service-worker.mjs scripts/check-release.mjs ./scripts/
|
||||
RUN npm run build && npm run check:release
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:stable-alpine AS runtime
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/ /srv/crystal/
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
|
||||
@@ -55,6 +55,148 @@ Use onscreen controls, or focus the game screen and use arrows to move, **X** to
|
||||
|
||||
Native saves are separate from emulator snapshots and cartridge `.sav` files, but Savegames → Import backup also reads a `.sav` of the English Crystal (from an emulator, a cartridge dump or the 3DS Virtual Console's `sav.dat`) and converts it into a new save after a preview. The cartridge's checksums decide which of its two copies is read, as the game would. With an emulator clock footer (VBA-M, mGBA, BGB, SameBoy), the game clock carries on from the cartridge's; otherwise it follows the device and the day continues from the save. Saves made in an area the port has not opened yet, during the Bug-Catching Contest or in a Battle Tower challenge are refused with the reason. Each save keeps its newest and previous write; if the newest was interrupted or damaged, the previous one loads and nothing is overwritten. The single save of earlier versions becomes "Save 1" the first time this version opens it. Saves and backups from a newer version are kept as they are and refused, never rewritten. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps.
|
||||
|
||||
## Deployment
|
||||
|
||||
Crystal is a static application: no application server, database or server-side save volume is needed. Each player supplies their ROM locally, and saves stay in their browser. Serve it at the root of a dedicated hostname over **HTTPS**; HTTP on `localhost` also works for local testing. Keep the same public origin when updating so players retain access to their browser saves.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Images are published to `git.azuze.fr/kawa/crystal:<tag>` for Linux amd64 and arm64. Choose a tag from the [Gitea packages](https://git.azuze.fr/kawa/-/packages). Save this as `compose.yaml` (also included in this repository):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
crystal:
|
||||
image: git.azuze.fr/kawa/crystal:${CRYSTAL_TAG:?Set CRYSTAL_TAG to a published image tag}
|
||||
ports:
|
||||
- "127.0.0.1:${CRYSTAL_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
```
|
||||
|
||||
Create a `.env` alongside it with `CRYSTAL_TAG=prod` for production or `CRYSTAL_TAG=dev` for testing. Each channel becomes available after its first successful publication. To pin a particular build, use its version tag (for example `v0.1.0`) or `dev-<commit SHA>` instead. Then start:
|
||||
|
||||
```sh
|
||||
# Only needed if the package is private; use a token with read:package.
|
||||
docker login git.azuze.fr
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:8080`. Set `CRYSTAL_PORT=8081` for a second stack on the same host, using a separate Compose project name (for example `docker compose -p crystal-dev up -d`). For access from other devices, configure your HTTPS reverse proxy to forward the dedicated hostname to `http://127.0.0.1:8080` (or the selected port). If the proxy is itself a container, put it on the same Docker network and forward to the appropriate stack's service on port 8080 instead. The application container serves HTTP on port 8080 as an unprivileged user and includes a health check. Only `/tmp` needs to be writable.
|
||||
|
||||
To update a channel, repeat `docker compose pull && docker compose up -d` with the same project name. To pin a version or roll back, change `CRYSTAL_TAG` to the desired published build first. Installed clients offer the new version through their Update button.
|
||||
|
||||
To build and run locally without the registry:
|
||||
|
||||
```sh
|
||||
docker build --pull -t crystal:local .
|
||||
docker run --rm -p 127.0.0.1:8080:8080 --read-only --tmpfs /tmp crystal:local
|
||||
```
|
||||
|
||||
The multi-stage [Dockerfile](Dockerfile) builds with Node 22 and serves only `dist/` with Nginx. The build runs `check:release`; [.dockerignore](.dockerignore) restricts the build context to native application inputs, excluding local ROMs, saves, reference tools and secrets.
|
||||
|
||||
### Bare-metal (Debian / Ubuntu with Nginx)
|
||||
|
||||
Install Node.js 22.12 or newer and npm on the build machine. Clone the repository, select the Git tag or commit to deploy, and build as your normal user:
|
||||
|
||||
```sh
|
||||
git clone https://git.azuze.fr/kawa/Crystal.git
|
||||
cd Crystal
|
||||
git checkout <git-tag-or-commit>
|
||||
npm ci
|
||||
npm run build
|
||||
npm run check:release
|
||||
```
|
||||
|
||||
On the server, install Nginx and copy **only the contents of `dist/`** into a dedicated document root. These commands assume the build is on the server; if built elsewhere, transfer `dist/` there first. Node.js is only needed for building.
|
||||
|
||||
```sh
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx rsync certbot python3-certbot-nginx
|
||||
sudo install -d -m 755 /var/www/crystal
|
||||
sudo rsync -a --delete --chmod=D755,F644 dist/ /var/www/crystal/
|
||||
```
|
||||
|
||||
Create `/etc/nginx/sites-available/crystal`, replacing `crystal.example.com` with your hostname:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name crystal.example.com;
|
||||
root /var/www/crystal;
|
||||
index index.html;
|
||||
|
||||
add_header Cache-Control "no-cache" always;
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Point the hostname's DNS to this server and allow inbound ports 80 and 443, then enable the site and obtain its HTTPS certificate:
|
||||
|
||||
```sh
|
||||
sudo ln -s /etc/nginx/sites-available/crystal /etc/nginx/sites-enabled/crystal
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
sudo certbot --nginx --redirect -d crystal.example.com
|
||||
```
|
||||
|
||||
Open `https://crystal.example.com`. For updates, check out the desired revision, repeat `npm ci`, the build and release check, then synchronize `dist/` again. Nginx does not need restarting for static file updates. The shell and service worker revalidate, while Vite's fingerprinted assets are cached for a year. Keep this site at the hostname root with the configuration above; subdirectory hosting needs a matching Vite base path and server configuration.
|
||||
|
||||
### Gitea image publication
|
||||
|
||||
[Publish container image](.gitea/workflows/publish-image.yml) separates production releases from manual test builds:
|
||||
|
||||
| Trigger | Published image tags | Deployment channel |
|
||||
| --------------------------------------- | ------------------------------- | ------------------ |
|
||||
| Push Git tag `v1.2.3` or `1.2.3` | Original version tag and `prod` | Production |
|
||||
| Manual dispatch with `dev-<commit SHA>` | Requested dev tag and `dev` | Test |
|
||||
|
||||
Configure these repository **Settings → Actions → Secrets** first:
|
||||
|
||||
| Secret | Value |
|
||||
| ------------------- | ----------------------------------------------------------------------- |
|
||||
| `REGISTRY_USERNAME` | Gitea login of the account publishing packages under `kawa`. |
|
||||
| `REGISTRY_TOKEN` | Personal access token for that account with `write:package` permission. |
|
||||
|
||||
The workflow uses an explicit registry token for [Gitea container registry authentication](https://docs.gitea.com/usage/packages/container/). The publishing account must be allowed to write packages under the repository owner. For a fork, the image path automatically follows `<Gitea host>/<owner>/<repository in lowercase>`; adjust the Compose image accordingly.
|
||||
|
||||
The existing `docker-build` runner must have a Docker daemon and allow the job to mount `/var/run/docker.sock`. In the [runner configuration](https://docs.gitea.com/runner/reference/config-example/), allow that exact path in `container.valid_volumes` and restart the runner after configuration changes. This grants jobs access to the host Docker daemon, so use a runner reserved for trusted repositories. The workflow installs its Docker client and Buildx; no privileged Docker-in-Docker service or QEMU is needed because the static build runs on the builder's own architecture.
|
||||
|
||||
- **Production:** push a stable version tag on a commit containing this workflow: `git tag v0.1.0 && git push origin v0.1.0`. That commit is built and published as both `git.azuze.fr/kawa/crystal:v0.1.0` and `git.azuze.fr/kawa/crystal:prod`. Accepted versions are `MAJOR.MINOR.PATCH`, optionally prefixed with `v`, without leading zeros, prerelease suffixes or build metadata. Other Git tag pushes fail validation before building or publishing.
|
||||
- **Test:** open **Actions → Publish container image → Run workflow**, select the source branch or Git tag, and fill in the required `tag` field with `dev-<commit SHA>`. Use 7–40 lowercase hexadecimal characters matching the selected source's commit, for example `dev-abcdef0`. The workflow verifies the prefix against the checked-out commit and fails on a mismatch. It builds the selected source; the input does not switch the checkout to another commit. Obtain the identifier with `git rev-parse --short=12 HEAD` after checking out the intended source. This publishes the requested dev tag and `git.azuze.fr/kawa/crystal:dev`, even when the selected source is a release tag. A manual run cannot publish to `prod`. The workflow must be on the default branch for the manual entry point to appear.
|
||||
|
||||
Each channel tracks the last successful publication to that channel, not the highest version number or newest Git commit. Republishing an older release deliberately moves `prod` back; concurrent builds update their channel in publication order. No shared `latest` alias is published. Before publishing, the workflow checks release isolation and starts a temporary container to verify that the page and service worker are served with the same read-only settings as Compose. Published images record the source commit in OCI image labels.
|
||||
|
||||
### Automatic deployment with Dockhand
|
||||
|
||||
Create two Git stacks using this repository and `compose.yaml`, with distinct stack names and HTTPS hostnames:
|
||||
|
||||
| Stack | Compose variables | Webhook secrets in Gitea Actions |
|
||||
| -------------- | --------------------------------------- | ----------------------------------------------------------- |
|
||||
| `crystal-prod` | `CRYSTAL_TAG=prod`, `CRYSTAL_PORT=8080` | `DOCKHAND_PROD_WEBHOOK_URL`, `DOCKHAND_PROD_WEBHOOK_SECRET` |
|
||||
| `crystal-dev` | `CRYSTAL_TAG=dev`, `CRYSTAL_PORT=8081` | `DOCKHAND_DEV_WEBHOOK_URL`, `DOCKHAND_DEV_WEBHOOK_SECRET` |
|
||||
|
||||
Configure each stack's Compose variables in Dockhand, enable its webhook and copy the URL and secret into the corresponding Gitea secrets. Give Dockhand registry credentials with `read:package` if the images are private. On both stacks enable **Re-pull images** and **Force redeployment**, so an unchanged Compose file still picks up a newly published channel image. The runner must be able to reach the webhook. See the [Dockhand Git integration and CI/CD documentation](https://dockhand.pro/manual/#git-integration).
|
||||
|
||||
After a successful image push, the workflow sends a signed request only to that channel's webhook. Both webhook secrets are optional: when neither is configured for a channel, publication succeeds without requesting deployment. Configuring only one is an error. A failed webhook marks the workflow failed, but the image is already published; correct the configuration and redeploy the stack in Dockhand. A successful request confirms acceptance, so check the actual deployment result in Dockhand.
|
||||
|
||||
Alternatively, leave webhook secrets unset and enable Dockhand's scheduled image checks with automatic updates for the stacks following `prod` and `dev`. These follow changes to the channel image; a stack pinned to a version stays on that version. Production and test should use different hostnames so their browser saves and service-worker caches remain separate.
|
||||
|
||||
## Reference emulator
|
||||
|
||||
During development, open **`/reference.html`** to run the original game for comparison. It has separate saves and does not run inside the native player. Reference tools and the emulator are excluded from the default build.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
crystal:
|
||||
image: git.azuze.fr/kawa/crystal:${CRYSTAL_TAG:?Set CRYSTAL_TAG to a published image tag}
|
||||
ports:
|
||||
- "127.0.0.1:${CRYSTAL_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
server_name _;
|
||||
root /srv/crystal;
|
||||
index index.html;
|
||||
|
||||
# Revalidate the shell and service worker so installed apps see updates.
|
||||
add_header Cache-Control "no-cache" always;
|
||||
|
||||
# Vite fingerprints these files; their URLs change with their content.
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/** Keep manual test builds out of the production channel. */
|
||||
export function imagePublication({ event, ref, inputTag, revision }) {
|
||||
if (!/^[0-9a-f]{40}$/.test(revision))
|
||||
throw new Error("Expected the full SHA-1 of the checked-out commit.");
|
||||
|
||||
let tag, channel;
|
||||
if (event === "workflow_dispatch") {
|
||||
tag = inputTag ?? "";
|
||||
const commit = /^dev-([0-9a-f]{7,40})$/.exec(tag)?.[1];
|
||||
if (!commit || tag !== tag.trim())
|
||||
throw new Error(
|
||||
"Manual image tags must be dev-<commit SHA> (7-40 lowercase hex characters).",
|
||||
);
|
||||
if (!revision.startsWith(commit))
|
||||
throw new Error(
|
||||
"The dev tag does not match the checked-out commit. Select the matching source branch or Git tag.",
|
||||
);
|
||||
channel = "dev";
|
||||
} else if (event === "push" && ref?.startsWith("refs/tags/")) {
|
||||
tag = ref.slice("refs/tags/".length);
|
||||
if (
|
||||
!/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag) ||
|
||||
tag.length > 128 ||
|
||||
tag !== tag.trim()
|
||||
)
|
||||
throw new Error(
|
||||
"Production requires a stable Git version tag: vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH.",
|
||||
);
|
||||
channel = "prod";
|
||||
} else {
|
||||
throw new Error(
|
||||
"Image publication requires a version tag push or a manual dev build.",
|
||||
);
|
||||
}
|
||||
return { tag, channel, revision };
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
const env = process.env;
|
||||
const revision = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const publication = imagePublication({
|
||||
event: env.EVENT_NAME,
|
||||
ref: env.GIT_REF,
|
||||
inputTag: env.INPUT_TAG,
|
||||
revision,
|
||||
});
|
||||
const registry = new URL(env.SERVER_URL).host;
|
||||
const image = `${registry}/${env.REPOSITORY.toLowerCase()}`;
|
||||
const source = `${env.SERVER_URL.replace(/\/$/, "")}/${env.REPOSITORY}`;
|
||||
const outputs = { registry, image, source, ...publication };
|
||||
appendFileSync(
|
||||
env.GITHUB_OUTPUT,
|
||||
Object.entries(outputs)
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(""),
|
||||
);
|
||||
console.log(
|
||||
`Publishing ${image}:${publication.tag} and :${publication.channel} from ${revision}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export async function notifyDockhand({ url, secret }, send = fetch) {
|
||||
if (!url && !secret) return false;
|
||||
if (!url || !secret)
|
||||
throw new Error(
|
||||
"Configure both Dockhand webhook URL and secret for this channel.",
|
||||
);
|
||||
const target = new URL(url);
|
||||
if (!["http:", "https:"].includes(target.protocol))
|
||||
throw new Error("Dockhand webhook requires an HTTP(S) URL.");
|
||||
const body = "{}";
|
||||
const signature = createHmac("sha256", secret).update(body).digest("hex");
|
||||
const response = await send(target, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": `sha256=${signature}`,
|
||||
},
|
||||
body,
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Dockhand rejected the deployment request (HTTP ${response.status}).`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
try {
|
||||
const sent = await notifyDockhand({
|
||||
url: process.env.DOCKHAND_WEBHOOK_URL,
|
||||
secret: process.env.DOCKHAND_WEBHOOK_SECRET,
|
||||
});
|
||||
console.log(
|
||||
sent
|
||||
? "Dockhand deployment requested. Check the stack's deployment status in Dockhand."
|
||||
: "Dockhand webhook not configured for this channel; image publication is complete.",
|
||||
);
|
||||
} catch {
|
||||
// Do not print the URL, secret or server response into the job log.
|
||||
console.error(
|
||||
"Dockhand deployment request failed. The image was already published; check this channel's webhook configuration and connectivity.",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { imagePublication } from "../scripts/image-publication.mjs";
|
||||
import { notifyDockhand } from "../scripts/notify-dockhand.mjs";
|
||||
|
||||
const revision = "abcdef01".repeat(5);
|
||||
const publish = (event: string, ref: string, inputTag = "") =>
|
||||
imagePublication({ event, ref, inputTag, revision });
|
||||
|
||||
describe("image publication channels", () => {
|
||||
it.each(["v1.2.3", "1.2.3", "v0.0.0", "v10.20.300"])(
|
||||
"publishes stable version %s to prod",
|
||||
(tag) =>
|
||||
expect(publish("push", `refs/tags/${tag}`)).toEqual({
|
||||
tag,
|
||||
channel: "prod",
|
||||
revision,
|
||||
}),
|
||||
);
|
||||
|
||||
it.each(["abcdef0", revision])(
|
||||
"publishes a matching manual commit %s only to dev",
|
||||
(commit) => {
|
||||
expect(
|
||||
publish("workflow_dispatch", "refs/heads/main", `dev-${commit}`),
|
||||
).toEqual({ tag: `dev-${commit}`, channel: "dev", revision });
|
||||
expect(
|
||||
publish("workflow_dispatch", "refs/tags/v1.2.3", `dev-${commit}`)
|
||||
.channel,
|
||||
).toBe("dev");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"v1.2.3-rc.1",
|
||||
"v1.2.3+build",
|
||||
"v01.2.3",
|
||||
"v1.2",
|
||||
"prod",
|
||||
"latest",
|
||||
"dev-abcdef0",
|
||||
"release/v1.2.3",
|
||||
"v1.2.3\nchannel=prod",
|
||||
"v1.2.3\n",
|
||||
])("rejects non-release Git tag %s before publication", (tag) =>
|
||||
expect(() => publish("push", `refs/tags/${tag}`)).toThrow(),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"v1.2.3",
|
||||
"prod",
|
||||
"latest",
|
||||
"dev",
|
||||
"",
|
||||
"dev-1234567",
|
||||
"dev-abcdef",
|
||||
"dev-ABCDEF0",
|
||||
"dev-abcdef0\nchannel=prod",
|
||||
"dev-abcdef0\n",
|
||||
"dev-$(exit 1)",
|
||||
])("rejects manual tag %s instead of promoting it", (tag) =>
|
||||
expect(() =>
|
||||
publish("workflow_dispatch", "refs/heads/main", tag),
|
||||
).toThrow(),
|
||||
);
|
||||
|
||||
it("does not publish branches or pull requests", () => {
|
||||
expect(() => publish("push", "refs/heads/main")).toThrow();
|
||||
expect(() => publish("pull_request", "refs/tags/v1.2.3")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional Dockhand deployment", () => {
|
||||
const url = "https://dockhand.example.com/api/git/stacks/42/webhook";
|
||||
const secret = "test-webhook-secret";
|
||||
|
||||
it("does not contact Dockhand when this channel has no configuration", async () => {
|
||||
const send = vi.fn();
|
||||
await expect(notifyDockhand({}, send)).resolves.toBe(false);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([{ url }, { secret }])(
|
||||
"rejects incomplete configuration",
|
||||
async (config) => {
|
||||
const send = vi.fn();
|
||||
await expect(notifyDockhand(config, send)).rejects.toThrow();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("signs exactly the posted payload and refuses redirects", async () => {
|
||||
const send = vi.fn().mockResolvedValue(new Response("{}"));
|
||||
await expect(notifyDockhand({ url, secret }, send)).resolves.toBe(true);
|
||||
const [target, request] = send.mock.calls[0];
|
||||
expect(target.href).toBe(url);
|
||||
expect(request.method).toBe("POST");
|
||||
expect(request.body).toBe("{}");
|
||||
expect(request.redirect).toBe("error");
|
||||
expect(request.headers["X-Hub-Signature-256"]).toBe(
|
||||
`sha256=${createHmac("sha256", secret).update(request.body).digest("hex")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports failed deployment requests", async () => {
|
||||
const send = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response("private server details", { status: 403 }),
|
||||
);
|
||||
await expect(notifyDockhand({ url, secret }, send)).rejects.toThrow(
|
||||
"HTTP 403",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user